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

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}")
+121 -26
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("商品语义重构模型未配置,请联系管理员")
provider = get_text_provider(model_config)
text, _payload = _collect_extract_text(
provider,
model_config,
build_product_semantic_remix_messages(
logger.warning("商品语义重构模型未配置,回退至安全展示分镜")
return build_safe_product_showcase_prompt(
digest_text=digest_text,
product_facts=product_facts,
duration=duration,
aspect_ratio=aspect_ratio,
),
temperature=0.2,
extra_body={"max_tokens": DIGEST_MAX_TOKENS},
product_name=product_name,
)
provider = get_text_provider(model_config)
messages = build_product_semantic_remix_messages(
digest_text=digest_text,
product_facts=product_facts,
duration=duration,
aspect_ratio=aspect_ratio,
)
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},
)
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,
)
return validate_product_semantic_remix(text)
def build_character_replace_prompt(