视频复刻优化替换商品和替换角色

This commit is contained in:
Azmat@qq.com
2026-08-31 16:40:16 +08:00
parent db71b29009
commit e24ca1b0de
9 changed files with 253 additions and 71 deletions
+45 -6
View File
@@ -22,6 +22,7 @@ from apps.ai.video_replace import (
advance_video_replace,
build_character_replace_prompt,
build_product_replace_prompt,
rewrite_product_semantic_remix as _real_rewrite_product_semantic_remix,
run_replace_digest,
submit_video_replace,
)
@@ -478,7 +479,11 @@ class SubmitVideoReplaceTests(TestCase):
return task
def test_product_semantic_remix_uses_facts_not_source_product_actions(self):
from apps.ai.video_replace import build_product_semantic_remix_messages, validate_product_semantic_remix
from apps.ai.video_replace import (
build_product_semantic_remix_messages,
build_safe_product_showcase_prompt,
validate_product_semantic_remix,
)
messages = build_product_semantic_remix_messages(
digest_text=DIGEST_SAMPLE,
@@ -489,10 +494,42 @@ class SubmitVideoReplaceTests(TestCase):
request = messages[1]["content"]
self.assertIn("蓝牙耳机", request)
self.assertIn("旧牌精华", request) # 参考动作仅作为要剥离的输入
self.assertIn("商品图片只用于外观", messages[0]["content"])
self.assertIn("目标商品的外观、颜色、材质、形状", messages[0]["content"])
self.assertIn("安全展示兜底", request)
self.assertIn("切换顺序和剪辑点必须与拆解稿完全一致", request)
self.assertIn("绝不能改成旁白", request)
self.assertIn("不得说出新商品名称", request)
self.assertIn("不要机械复刻原商品交互", request)
self.assertIn("保留原口播的句数、时间位置、语速节奏和情绪", request)
self.assertEqual(validate_product_semantic_remix(SEMANTIC_SAMPLE), SEMANTIC_SAMPLE.strip())
with self.assertRaisesMessage(ValueError, "商品资料不足"):
validate_product_semantic_remix("MISSING_PRODUCT_FACTS:缺少商品品类")
compiled = validate_product_semantic_remix(
"原商品依赖分析(内部结果)\n【SEEDANCE_PROMPT】\n" + SEMANTIC_SAMPLE
)
self.assertEqual(compiled, SEMANTIC_SAMPLE.strip())
self.assertEqual(validate_product_semantic_remix("MISSING_PRODUCT_FACTS:缺少商品品类"), "")
fallback = build_safe_product_showcase_prompt(digest_text=DIGEST_SAMPLE, product_name="蓝牙耳机")
self.assertIn("@目标商品", fallback)
self.assertNotIn("蓝牙耳机", fallback)
self.assertIn("画内人物对白", fallback)
self.assertNotIn("旧牌精华", fallback)
self.assertNotIn("倒进玻璃杯", fallback)
def test_product_semantic_remix_falls_back_to_safe_showcase_on_missing_facts(self):
"""当 LLM 返回 MISSING_PRODUCT_FACTS 时,不抛错,而是平滑兜底为安全展示分镜。"""
text_model = ModelConfig.objects.filter(capability="text", status="active").first()
with patch("apps.ai.video_digest.resolve_digest_model_config", return_value=text_model), \
patch("apps.ai.services._collect_extract_text", return_value=("MISSING_PRODUCT_FACTS: 缺少商品品类", {})):
prompt = _real_rewrite_product_semantic_remix(
task=None,
digest_text=DIGEST_SAMPLE,
product_facts={"商品名称": "随手拍水杯", "资料状态": "不足"},
duration=8,
aspect_ratio="9:16",
)
self.assertIn("@目标商品", prompt)
self.assertNotIn("随手拍水杯", prompt)
self.assertIn("只按参考图展示外观、包装和材质", prompt)
self.assertNotIn("旧牌精华", prompt)
def test_product_triview_is_sent_with_the_video(self):
"""商品库选商品时,三视图要跟着一起发给火山,并在提示词里被点名。"""
@@ -587,8 +624,10 @@ class SubmitVideoReplaceTests(TestCase):
self.assertIn("@目标角色", prompt)
self.assertIn("@参考视频", prompt)
self.assertIn("商品", prompt)
self.assertIn("绝不继承参考视频中任何人的原始声线", prompt)
self.assertIn("目标角色为男性时使用自然成年男声", prompt)
self.assertIn("声音重建规则——最高优先级", prompt)
self.assertIn("被替换人物对应的所有声音必须同步替换", prompt)
self.assertIn("不得出现原人物声线残留", prompt)
self.assertIn("嘴部动作必须与重新生成的人声一致", prompt)
self.assertNotIn("@目标商品", prompt)
def test_digest_generates_once_without_source_video(self):
+4 -4
View File
@@ -109,7 +109,7 @@ def load_digest_skill() -> str:
"你是分镜拆解 agent。输入是一条短视频的完整文件,含画面和音轨。"
"必须覆盖全片,从 00:00 写到片尾,有几镜写几镜,不要概括成几大段。"
"每镜按【镜头 01】写出时间、时长、景别、机位、运镜、画面、人物动作、人物表情、"
"台词/旁白、音效、背景音乐、字幕、备注。没有就写无。输出中文纯文本。"
"人声方式、台词/旁白、音效、背景音乐、字幕、备注。人声方式只能写画内人物对白、画外旁白或无;没有就写无。输出中文纯文本。"
)
@@ -435,15 +435,15 @@ def build_digest_messages(
head = [
f"这是一条{measured}的短视频**完整文件**(含画面和口播音轨)。{title_hint}",
"请按技能还原全片导演分镜稿:从 00:00 写到片尾,有几镜写几镜。",
"每镜必须写齐时间、时长、景别、机位、运镜、画面、人物动作、人物表情、台词/旁白、音效、背景音乐、字幕、备注。",
"台词/旁白按听到的口播逐字写;画面上的花字写进字幕。",
"每镜必须写齐时间、时长、景别、机位、运镜、画面、人物动作、人物表情、人声方式、台词/旁白、音效、背景音乐、字幕、备注。",
"人声方式必须区分画内人物对白、画外旁白或无;台词/旁白按听到的口播逐字写,画面中人物开口时必须标画内人物对白;画面上的花字写进字幕。",
]
else:
head = [
f"这是一条{measured}的短视频,",
f"按时间顺序均匀抽了 {len(frames)} 帧。每帧图前面标了它在原片中的时间点。{title_hint}",
"请按技能还原**全片**导演分镜稿:从 00:00 写到片尾,有几镜写几镜。",
"每镜必须写齐时间、时长、景别、机位、运镜、画面、人物动作、人物表情、台词/旁白、音效、背景音乐、字幕、备注。",
"每镜必须写齐时间、时长、景别、机位、运镜、画面、人物动作、人物表情、人声方式、台词/旁白、音效、背景音乐、字幕、备注。",
]
if product_hint:
head.append(f"用户接下来想用这条片子的结构去拍自己的商品:{product_hint}")
+116 -21
View File
@@ -86,11 +86,21 @@ PRODUCT_PROMPT = (
"严格遵守每镜的新商品动作、场景与口播;不要恢复参考片原商品、原动作、原卖点或原台词。"
)
CHARACTER_PROMPT = (
"@参考视频的镜头、人物动作、场景、节奏、口播和剪辑为唯一基准生成视频。"
"将画面中所有可见的原人物替换为@目标角色;五官、发型、体态和服装以角色参考图为准"
"保留原商品、场景、镜头运动、动作顺序、语速、停顿和情绪节奏,不添加字幕、花字或额外人物。"
"绝不继承参考视频中任何人的原始声线或人声:所有对白、旁白、口型和声音都必须与@目标角色的性别与身份一致,"
"目标角色为男性时使用自然成年男声,目标角色为女性时使用自然成年女声。"
"@参考视频仅用于参考镜头构图、镜头运动、人物动作、动作顺序、场景、商品、剪辑结构、画面节奏,"
"以及原台词表达的文字语义和时间结构"
"将@参考视频中的原人物替换为@目标角色。目标人物的五官、发型、体态、年龄感、服装和整体形象,"
"以@目标角色为唯一基准。"
"保留原视频中的商品、场景、人物动作、镜头运动、动作顺序、剪辑点、画面节奏和故事表达,"
"不添加字幕、花字、额外人物或无关元素。"
"【声音重建规则——最高优先级】被替换人物对应的所有声音必须同步替换,包括画面内对白、口播、旁白、画外音、"
"语气词和呼吸声。只保留原口播的文字内容、信息含义、句子顺序、出现时间、停顿位置、情绪类型和表达节奏;"
"不得保留原人物的声线、音色、音高、年龄感、性别特征、口音、声纹或其他说话人身份特征。"
"根据@目标角色的身份及系统提供的目标声音设定,重新生成全部相关人声。"
"从视频开始到结束,被替换人物的声音必须始终使用同一个目标声音;不得出现原人物声线残留、声音身份中途变化、"
"新旧声音混用,或画面角色已经替换但声音仍属于原人物的情况。"
"画面中目标角色的嘴部动作必须与重新生成的人声一致,口型、台词开始时间、停顿、语速和情绪表达保持同步。"
"与人物身份无关的背景音乐、环境音和必要音效可以保留,但其中不得混入原人物的人声或声纹。"
"如保留原视频声音与目标角色声音冲突,必须舍弃原人物声音,以重新生成的目标声音为最高优先级。"
)
# 拆解期间的占位提示词。真正的提示词在 worker 拆完后回写。
@@ -380,7 +390,10 @@ def _product_semantic_facts(product: Product) -> dict:
"真实卖点": selling_points,
"目标用户": product.target_audience.strip(),
"规格": product.specs or {},
"允许使用场景": (product.specs or {}).get("allowed_use_cases") or (product.specs or {}).get("使用场景") or [],
"禁止宣称": (product.specs or {}).get("prohibited_claims") or (product.specs or {}).get("禁止宣称") or [],
"资料状态": "完整" if (product.category.strip() and (product.description.strip() or selling_points)) else "不足",
"资料不足时策略": "安全展示兜底:只允许拿取、手持展示、放置、携带、包装/材质/外观特写和在桌面陈列;禁止打开、食用、佩戴、插入、涂抹、连接、充电、清洁、宣称功效或任何未确认用途。",
}
@@ -398,22 +411,87 @@ def build_product_semantic_remix_messages(*, digest_text: str, product_facts: di
f"{facts}\n\n"
"【参考视频拆解稿,仅可继承角色关系、环境、镜头语言、剪辑节奏和叙事功能】\n"
f"{_digest_for_seedance(digest_text)}\n\n"
"输出给视频模型的中文分镜稿:保留【镜头 01】格式和每镜时间、景别、机位、运镜;"
"保留参考片的人物、场景、拍摄视角、镜头构图、运镜、剪辑顺序、节奏、情绪和营销结构;将原商品完整替换为目标商品。"
"先在内部完成原商品生态、依赖道具、使用接口、动作链与逐镜兼容性检查;不要机械复刻原商品交互。"
"原镜用于取出、展示、使用或证明卖点时,保留该镜景别、运镜、时长和表达目的,"
"但仅在动作适合目标商品时保留,否则按目标商品结构、用途和卖点重新设计合理动作。"
"所有持握、开启、穿戴、使用、安装和展示必须符合真实物理常识:不得尺寸不匹配、虚构配件、商品变形换款变色,"
"也不得生成未经商品信息支持的功能。"
"如果商品资料状态为‘不足’,不要输出 NEEDS_PRODUCT_FACTS:改用商品资料中的安全展示兜底,"
"把原片所有商品使用镜改写为不涉及具体用途的展示镜头。"
"镜头数量、编号、时间区间、时长、景别、机位、运镜、切换顺序和剪辑点必须与拆解稿完全一致,绝不能增删、合并或调换镜头。"
"参考镜为画内人物对白时,成片必须由画面内人物直接开口说话并口型同步,绝不能改成旁白。"
"任何人声都不得说出新商品名称、品牌或旧商品名称,统一用‘这个’、‘它’等自然指代。"
"必须同步重写与原商品有关的口播、音效和画面内容;保留原口播的句数、时间位置、语速节奏和情绪,"
"但不得保留原商品的名称、功能和卖点。"
"最后只输出【SEEDANCE_PROMPT】后的中文分镜稿:保留【镜头 01】格式和每镜时间、时长、景别、机位、运镜、人声方式;"
"每镜的画面、人物动作、台词/旁白必须按新商品真实用途重写。"
"不要输出分析过程、分类标签或 Markdown。"
"禁止输出分析过程、分类标签或 Markdown。"
),
},
]
def validate_product_semantic_remix(text: str) -> str:
"""拒绝模型在事实不足时瞎编,或只给一段泛泛分析而没有可出片分镜"""
"""取出模型的最终分镜稿;任何不可用结果由调用方自动降级,绝不阻断用户任务"""
cleaned = (text or "").strip()
if "MISSING_PRODUCT_FACTS" in cleaned:
raise ValueError("商品资料不足:请先在商品库补充品类,以及商品描述或至少一条真实卖点后再复刻")
if len(cleaned) < 120 or "【镜头" not in cleaned:
raise ValueError("商品语义重构结果不完整,请重试")
return cleaned
# skill 的结构化输出只把最终可出片部分送到 Seedance,避免把原商品分析带进提示词。
marker = "【SEEDANCE_PROMPT】"
if marker in cleaned:
cleaned = cleaned.split(marker, 1)[1].strip()
# 这些是模型的“索取资料”占位语,不能作为视频提示词发送;返回空串使上层直接走展示兜底。
if any(token in cleaned for token in ("MISSING_PRODUCT_FACTS", "NEEDS_PRODUCT_FACTS", "MANUAL_REVIEW_REQUIRED")):
return ""
return cleaned if len(cleaned) >= 120 and "【镜头" in cleaned else ""
def build_safe_product_showcase_prompt(*, digest_text: str, product_name: str) -> str:
"""资料不足时的确定性兜底:不依赖模型猜用途,也绝不沿用原商品动作。"""
from .services import enforce_no_embedded_captions
header, shots = _parse_digest(digest_text)
safe_actions = (
"人物从干净桌面拿起@目标商品,保持自然手持展示。",
"人物将@目标商品平稳放在桌面上,镜头展示包装轮廓。",
"人物缓慢转动@目标商品,展示外观、包装和材质细节。",
"镜头靠近@目标商品,拍摄包装与可见结构特写。",
)
safe_dialogues = (
"你看这个,整体的外观和质感都很清楚。",
"我把它转过来,细节可以看得更完整。",
"从这个角度看,包装和轮廓很直观。",
"镜头靠近一点,看看它的材质细节。",
)
lines = [
"【成片规则】",
"@目标商品只按参考图展示外观、包装和材质。",
"不得出现原商品、原容器、原配件、原动作、原台词、功效或任何未经确认的使用方式。",
"镜头数量、时间、时长、景别、机位、运镜和切换顺序必须与参考分镜一致;不得增删、合并、拆分或调换镜头。",
"所有人声禁止说出商品名称和品牌。参考镜中人物开口说话时,必须生成画内人物对白和同步口型,禁止改成旁白。",
]
if header.get("整体风格"):
lines.append(f"整体风格:{header['整体风格']}")
for index, shot in enumerate(shots, start=1):
lines.extend([
"",
f"【镜头 {index:02d}",
*(f"{field}{shot[field]}" for field in ("时间", "时长", "景别", "机位", "运镜") if shot.get(field)),
f"画面:保持原镜的环境氛围、人物关系与镜头节奏,只出现@目标商品作为展示主体。",
f"人物动作:{safe_actions[(index - 1) % len(safe_actions)]}",
"人物表情:自然、专注。",
f"人声方式:{'画内人物对白' if shot.get('台词/旁白') and not _is_empty_value(shot.get('台词/旁白') or '') else ''}",
f"台词/旁白:{'画内人物对白(人物对镜开口说话,非旁白):' + safe_dialogues[(index - 1) % len(safe_dialogues)] if shot.get('台词/旁白') and not _is_empty_value(shot.get('台词/旁白') or '') else ''}",
])
if not shots:
lines.extend([
"", "【镜头 01】", "时长:5秒", "景别:中近景", "机位:平视", "运镜:缓慢推近",
"画面:干净自然的环境中,@目标商品置于桌面中央。",
"人物动作:人物自然手持展示@目标商品。",
"人物表情:自然、专注。",
"人声方式:画内人物对白。",
"台词/旁白:画内人物对白(人物对镜开口说话,非旁白):你看这个,外观和包装细节很清楚。",
])
return enforce_no_embedded_captions("\n".join(lines))
def rewrite_product_semantic_remix(*, task, digest_text: str, product_facts: dict, duration: int, aspect_ratio: str) -> str:
@@ -421,23 +499,40 @@ def rewrite_product_semantic_remix(*, task, digest_text: str, product_facts: dic
from .video_digest import DIGEST_MAX_TOKENS, resolve_digest_model_config
from .services import _collect_extract_text, get_text_provider
product_name = str(product_facts.get("商品名称") or "")
model_config = resolve_digest_model_config()
if model_config is None:
raise ValueError("商品语义重构模型未配置,请联系管理员")
logger.warning("商品语义重构模型未配置,回退至安全展示分镜")
return build_safe_product_showcase_prompt(
digest_text=digest_text,
product_name=product_name,
)
provider = get_text_provider(model_config)
text, _payload = _collect_extract_text(
provider,
model_config,
build_product_semantic_remix_messages(
messages = build_product_semantic_remix_messages(
digest_text=digest_text,
product_facts=product_facts,
duration=duration,
aspect_ratio=aspect_ratio,
),
temperature=0.2,
)
last_error: Exception | None = None
try:
text, _payload = _collect_extract_text(
provider, model_config, messages, temperature=0.2,
extra_body={"max_tokens": DIGEST_MAX_TOKENS},
)
return validate_product_semantic_remix(text)
prompt = validate_product_semantic_remix(text)
if prompt:
return prompt
last_error = ValueError("模型未提供可用分镜")
except Exception as exc:
logger.warning("商品语义重构模型调用异常,直接使用展示分镜: %s", exc)
last_error = exc
# 不再因资料、动作兼容或模型格式而拒绝任务:始终继续走 Seedance。
logger.info("商品语义重构未产出可用分镜,自动使用展示分镜 (原因: %s)", last_error)
return build_safe_product_showcase_prompt(
digest_text=digest_text,
product_name=product_name,
)
def build_character_replace_prompt(
@@ -1329,3 +1329,4 @@ class QuickCreateCoordinatorTests(TestCase):
self.assertEqual(submit_video.call_count, 2)
statuses = list(self.project.video_segments.order_by("sort_order").values_list("status", flat=True))
self.assertEqual(statuses, [VideoSegment.Status.QUEUED, VideoSegment.Status.QUEUED])
@@ -1,46 +1,58 @@
---
name: product-semantic-video-remix
description: 商品语义重构式视频复刻。保留参考片的表达框架,新商品真实资料重写可执行的视频分镜
description: 跨品类商品替换的语义重构规则。参考片只提供视频表达框架,新商品事实决定动作、道具、台词与结果
---
# 商品语义重构式视频复刻
## 目标
## 核心边界
参考视频仅提供角色关系、环境氛围、镜头语言、剪辑节奏和叙事结构。必须移除原商品的名称、功能、动作、卖点、台词和因果关系,再依据新商品的真实资料重写每个镜头
保留参考视频中的人物、场景、拍摄视角、镜头构图、运镜、剪辑顺序、节奏、情绪和营销结构;将原商品完整替换为目标商品
## 最高优先级规则
参考视频只能提供角色关系、兼容环境、光线、景别、机位、运镜、剪辑节奏、情绪曲线、每镜叙事功能,以及人声出现的时间与方式。禁止继承原商品、包装、容器、配件、使用接口、动作、状态变化、作用对象、结果、卖点、品牌和台词。
1. 复刻镜头意图,不复刻原商品动作
2. 新商品真实用途优先于原片视觉相似度。
3. 商品图片只用于外观、包装、材质和颜色,不得据此编造功效、价格或营销承诺。
4. 每个动作都必须符合新商品的真实物理用途;原环境冲突时允许最小幅度调整场景。
5. 信息不足时只输出 `MISSING_PRODUCT_FACTS`,并列出缺少的品类、用途、真实卖点或使用场景;不得猜测后继续写分镜。
镜头骨架是硬约束:镜头数量、连续编号、每镜时间区间、时长、景别、机位、运镜、切换顺序和剪辑点必须与参考拆解稿逐项一致;不得合并、拆分、删减、增加或调换镜头。只允许重写与原商品绑定的画面内容、动作和人声文字
## 工作方法
目标商品的外观、颜色、材质、形状、尺寸比例、包装和组件以商品图片为准;商品类别、功能、卖点和使用方式以商品信息为准。商品名称和品牌仅用于内部识别,禁止出现在任何画内对白、口播、旁白或画面文字中。
先从参考分镜中分离四类信息:
## 内部工作步骤
- PRESERVE:角色数量与关系、环境气氛、镜头时长、景别、机位、运镜、剪辑节奏
- ADAPT:每镜叙事功能,例如痛点、展示、验证、收尾
- DISCARD:原商品、原包装、原功能、原动作、原台词、原卖点、原结果和绑定道具
- VALIDATE:原场景与新商品用途是否冲突;冲突时做最小场景调整。
1. 建立原商品依赖图:主体、容器、配件、使用接口、动作、状态、结果和专属道具
2. 提取可迁移视频 DNA:角色、兼容环境、镜头语言、节奏、情绪曲线及每镜的 Hook / 展示 / 证明 / CTA 功能
3. 建立新商品能力模型:形态、部件、可用容器、允许动作、使用者、接触位置、真实卖点、禁止动作与禁止宣称
4. 逐镜判定:不要机械复刻原商品交互。原镜用于取出、展示、使用或证明卖点时,保留该镜的景别、运镜、时长和表达目的;只有动作适合目标商品才保留,不适合则按目标商品的结构、用途和卖点重写合理动作。原片的形态、容器、动作、接口、状态和结果只要与新商品不兼容,就必须删除;环境仅做最小必要调整。
5. 同步重写:所有与原商品有关的口播、音效和画面内容必须改为准确描述目标商品。保留原视频口播的句数、时间位置、语速节奏和情绪,不得保留错误的商品名称、功能或卖点。
6. 逐镜校验:人物与商品的持握、开启、穿戴、使用、安装和展示必须符合真实物理常识;商品不得从尺寸不匹配的容器中取出,不得出现不存在的配件,不得变形、换款、变色或产生未经商品信息支持的功能。动作几何、容器尺度、状态前后关系、商品外观、持有关系、卖点事实和跨镜连续性全部成立才能出片。
然后只根据输入的商品名称、品类、描述、真实卖点、目标用户和规格,建立新商品的能力边界:它是什么、能做什么、不能做什么、谁在何处如何使用。不得把参考片的护肤、饮用、穿戴、清洁、数码操作等动作直接迁移到不同品类
不要把“把 A 换成 B,其他不变”当成替换。最低替换单位是:商品主体 → 部件/容器/配件 → 使用接口 → 动作 → 状态变化 → 可见结果
## 输出要求
## 信息不足与失败
只输出中文导演分镜稿,不输出分析、分类标签、解释或 Markdown。必须保留:
没有品类,或无法确认用途/真实卖点时,优先使用“安全展示兜底”继续:只可写拿取、手持展示、放置、携带、包装/材质/外观特写和桌面陈列;禁止打开、食用、佩戴、插入、涂抹、连接、充电、清洁、功效、结果和营销宣称。把原片所有商品使用镜改写为这些安全展示镜头,不得猜测具体用途。
- `【镜头 01】` 连续编号;
- 每镜的时间、时长、景别、机位、运镜;
- 依据新商品事实重写后的画面、人物动作、人物表情、台词/旁白;
- 角色关系、环境连续性和原片节奏。
即使资料不足或动作难以兼容,也必须继续输出安全展示分镜;不得输出索取资料、人工审核或拒绝生成的占位文字。
开头必须有 `【成片规则】`,明确:参考视频不决定新商品发生什么;新商品外观以 `@目标商品` 参考图为准;禁止出现原商品、原品牌、原包装、原功能和原台词。
## 最终输出
台词只能使用已提供的商品事实和真实卖点。没有可证实的功效时,改写为可见的使用、外观、携带、摆放或操作事实,不得虚构前后效果。
在内部完成上述分析与校验,但不要输出分析过程。仅输出以下内容:
## 逐镜校验
```
【SEEDANCE_PROMPT】
【成片规则】
参考视频拆解结果仅用于角色关系、可兼容环境、镜头语言、剪辑节奏、情绪曲线和叙事结构。禁止继承原商品及其容器、配件、使用动作、作用对象、状态变化、功能结果、商品台词和专属道具。
@目标商品:新商品外观、包装和材质参考。
生成前逐镜确认:新商品能否真的执行该动作、使用者与场景是否合理、动作是否证明真实卖点、前后是否连续、是否残留原商品语义、是否有未经提供依据的功效,以及商品是否保持与参考图一致。任一项不满足时,先重写该镜。
【镜头 01】
时间:...
时长:...
景别:...
机位:...
运镜:...
画面:...(新商品自己的合理道具、位置和状态)
人物动作:...(只使用允许动作)
人物表情:...
人声方式:画内人物对白 / 画外旁白 / 无(必须沿用参考片该镜的人声方式)
台词/旁白:...(只使用已确认事实;画内人物对白必须让画面中的角色开口说话、口型同步,禁止改成旁白;不得说商品名称或品牌)
```
镜头从 `【镜头 01】` 连续编号。必须保留原片的时间、时长、景别、机位、运镜、切换点、叙事功能和人声方式;重写画面、动作、台词与结果。禁止字幕、花字、原商品、原品牌、商品名称和未经证实的功效。
@@ -32,6 +32,7 @@ description: >
### 铁律 2 · 声音按听到的写
你拿得到音轨。
- `人声方式`:必须标明「画内人物对白」(看得到人物开口说话)、「画外旁白」(说话者不在画面内)或「无」。不可把画内人物对白笼统写成旁白。
- `台词/旁白`:口播或对白**原文**,逐字转写,不要润色、不要概括。
- `字幕`:画面上的花字 / 字幕**逐字照抄**;没有就写「无」。
- `音效`:雨声、脚步、开瓶、汽车经过等你确实听见的同期声。
@@ -67,6 +68,7 @@ description: >
画面:深夜街道被雨水覆盖,路灯倒映在积水中,一名黑衣女孩独自走过空旷街口。
人物动作:女孩低头行走,右手撑着黑伞。没有人物写「无」。
人物表情:警觉、疑惑。看不见脸写「不可见」。
人声方式:画内人物对白。
台词/旁白:是谁? 没有人声写「无」。
音效:雨声、远处汽车经过声。
背景音乐:低沉而克制的钢琴音。
@@ -82,6 +84,7 @@ description: >
画面:镜头跟随女孩前进,她的外套和头发被风吹动。
人物动作:女孩突然放慢脚步,微微转头。
人物表情:警觉、疑惑。
人声方式:画内人物对白。
台词/旁白:是谁?
音效:脚步声停止,雨声短暂增强。
背景音乐:钢琴停止,加入低频氛围音。
@@ -98,7 +101,7 @@ description: >
- 镜号写成 `【镜头 01】` 这种两位数字,从 01 连续递增。
- `时间``00:00-00:04` 这种分:秒闭区间,必须接上上一镜结束点,不能跳秒、不能重叠。
- `时长` 必须等于该镜时间区间的秒数。
- 上面列出的栏位**每一镜都要有**,不要省略;没有内容就写「无」或「不可见」。
- 上面列出的栏位**每一镜都要有**,不要省略;没有内容就写「无」或「不可见」。`人声方式` 只能是「画内人物对白 / 画外旁白 / 无」。
- **`【拆解存疑】`一节必须有**,哪怕只有一条。
- 全文中文,不出现 markdown 标题符号(`#`)和代码块围栏。
- 不要把一镜压成「画面 + 解说词」两行。栏位太少,下游脚本就只能得到一句摘要。
+4 -2
View File
@@ -37,6 +37,8 @@ const KIND_LABEL: Record<string, string> = { product: "商品", person: "角色"
const SOURCE_LABEL: Record<string, string> = { ai: "脚本辅助生成", theme: "脚本辅助生成", manual: "上传脚本", video: "上传视频提炼" };
// 上传自有脚本 / 上传视频提炼入口暂隐,改 true 即可恢复
const SHOW_SCRIPT_IMPORT = false;
// 脚本模型选择入口暂隐(目前仅一个模型),改 true 或多模型时可恢复
const SHOW_SCRIPT_MODEL_PICKER = false;
// 旁白配音音色预设(语音合成经典版试用包实测可用,与后端 VOICEOVER_VOICES 对齐)
const VO_VOICES = [
{ key: "BV700_streaming", label: "灿灿 · 活力女声" },
@@ -3350,8 +3352,8 @@ export function PipelinePage(props: {
</button>
</>
)}
{/* 模型选择小按钮(输入框下方,对齐 ChatGPT/Lovart)· 复用 restraint chip 下拉,向上展开 */}
{scriptPickerModels.length > 0 ? (
{/* 模型选择小按钮(输入框下方,对齐 ChatGPT/Lovart)· 目前单一模型先隐藏 */}
{SHOW_SCRIPT_MODEL_PICKER && scriptPickerModels.length > 1 ? (
<div ref={modelPickRef} className={`chip-wrap chat-model-pick${modelMenuOpen ? " open" : ""}`}>
<button type="button" className="chip" title="选择脚本生成模型" aria-expanded={modelMenuOpen} aria-haspopup="listbox" onClick={() => setModelMenuOpen((v) => !v)}>
{activeModelName}
+19 -5
View File
@@ -962,8 +962,11 @@ export function VideoReplacePage({
<div className="product-replace-options">
<button
type="button"
className={`replace-product-method${source === "library" ? " active" : ""}${librarySelected ? " has-product-preview" : ""}`}
className={`replace-product-method${source === "library" ? " active" : ""}${librarySelected ? " has-product-preview" : ""}${generating ? " is-disabled" : ""}`}
disabled={generating}
aria-disabled={generating}
onClick={() => {
if (generating) return;
if (replaceMode === "character") setPendingModelId(selectedModel?.id || "");
else setPendingProductId(selectedProduct?.id || "");
setLibraryOpen(true);
@@ -986,12 +989,17 @@ export function VideoReplacePage({
</button>
<div
className={`replace-product-method replace-temporary-method${source === "temporary" ? " active" : ""}${tempDisplay.length ? " has-images" : ""}${tempDrop.dragging ? " is-dragover" : ""}`}
className={`replace-product-method replace-temporary-method${source === "temporary" ? " active" : ""}${tempDisplay.length ? " has-images" : ""}${tempDrop.dragging ? " is-dragover" : ""}${generating ? " is-disabled" : ""}`}
role="button"
tabIndex={0}
{...tempDrop.dropProps}
onClick={() => tempInputRef.current?.click()}
tabIndex={generating ? -1 : 0}
aria-disabled={generating}
{...(!generating ? tempDrop.dropProps : {})}
onClick={() => {
if (generating) return;
tempInputRef.current?.click();
}}
onKeyDown={(event) => {
if (generating) return;
if (event.key === "Enter" || event.key === " ") {
event.preventDefault();
tempInputRef.current?.click();
@@ -1023,9 +1031,11 @@ export function VideoReplacePage({
type="button"
className="replace-temporary-remove"
aria-label={`删除第${index + 1}张临时${copy.temporaryNoun}`}
disabled={generating}
onClick={(event) => {
event.preventDefault();
event.stopPropagation();
if (generating) return;
if (tempFiles.length) {
setTempFiles((current) => current.filter((_, itemIndex) => itemIndex !== index));
} else {
@@ -1049,9 +1059,11 @@ export function VideoReplacePage({
<button
type="button"
className="replace-temporary-clear"
disabled={generating}
onClick={(event) => {
event.preventDefault();
event.stopPropagation();
if (generating) return;
if (!tempFiles.length && !tempAssetRefs.length) return;
setTempFiles([]);
setTempAssetRefs([]);
@@ -1072,8 +1084,10 @@ export function VideoReplacePage({
accept="image/jpeg,image/png,image/webp"
multiple
hidden
disabled={generating}
onClick={(event) => event.stopPropagation()}
onChange={(event) => {
if (generating) return;
addTempImages(event.target.files);
event.target.value = "";
}}
+17 -1
View File
@@ -503,11 +503,27 @@
border-radius: inherit;
}
.vrep-page .replace-product-method:hover {
.vrep-page .replace-product-method:hover:not(:disabled):not(.is-disabled) {
border-color: rgba(0, 47, 167, 0.28);
transform: translateY(-1px);
}
.vrep-page .replace-product-method:disabled,
.vrep-page .replace-product-method.is-disabled,
.vrep-page .replace-product-method[aria-disabled="true"] {
opacity: 0.55;
cursor: not-allowed;
transform: none !important;
pointer-events: none;
}
.vrep-page .replace-temporary-remove:disabled,
.vrep-page .replace-temporary-clear:disabled {
opacity: 0.45;
cursor: not-allowed;
pointer-events: none;
}
.vrep-page .replace-product-method.active {
border-color: var(--klein);
background: #f1f5ff;