diff --git a/core/backend/apps/ai/test_video_replace.py b/core/backend/apps/ai/test_video_replace.py index 739e34c..7ace441 100644 --- a/core/backend/apps/ai/test_video_replace.py +++ b/core/backend/apps/ai/test_video_replace.py @@ -700,6 +700,48 @@ class SubmitVideoReplaceTests(TestCase): self.assertFalse(task.request_payload.get("shot_plan")) self.assertNotIn("@参考视频", task.request_payload["prompt"]) + def test_subject_brief_reaches_product_facts(self): + """第 3 步填的商品信息要进 facts,并把「仅外观」升级成「部分」。""" + task = self._submit(subject_brief=" 素颜霜,膏体按压泵,主打提亮 ") + payload = task.request_payload + self.assertEqual(payload["subject_brief"], "素颜霜,膏体按压泵,主打提亮") + facts = payload["product_facts"] + self.assertEqual(facts["用户补充信息"], "素颜霜,膏体按压泵,主打提亮") + self.assertEqual(facts["资料状态"], "部分") + + def test_character_brief_drives_voice_gender(self): + """角色信息里的性别要变成写死的声线要求。 + + 这条守的是「女换男之后画面是男的、声音还是女的」:提示词以前引用了 + 一个根本不存在的「系统提供的目标声音设定」,模型只能猜,于是沿用原音轨。 + """ + from apps.ai.video_replace import build_character_submit_prompt + + male = build_character_submit_prompt("男性,30 岁左右,中低音域") + self.assertIn("成年男性声线", male) + self.assertNotIn("成年女性声线", male) + self.assertIn("男性,30 岁左右", male) + # 前缀必须原样保留:老任务靠 prompt 前缀认 replace_mode + self.assertTrue(male.startswith(CHARACTER_PROMPT)) + + self.assertIn("成年女性声线", build_character_submit_prompt("女性,25 岁,声音甜美")) + # 男女都提到(「把女的换成男的」)时不下断言,交给通用规则 + self.assertNotIn("【目标声音设定】", build_character_submit_prompt("把女的换成男的")) + # 没填也要有性别跟随规则,不能再引用不存在的设定 + bare = build_character_submit_prompt("") + self.assertIn("必须随目标角色改变", bare) + self.assertNotIn("系统提供的目标声音设定", bare) + + def test_character_brief_lands_in_submitted_prompt(self): + portrait = _asset(self.team, self.user, name="模特.png", preview="http://tos/model.png") + model = Model.objects.create(team=self.team, name="阿哲", portrait_asset=portrait) + task = self._submit( + replace_mode="character", product_id="", model_id=str(model.id), + subject_brief="男性,稳重,中低音域", + ) + self.assertEqual(task.request_payload["subject_brief"], "男性,稳重,中低音域") + self.assertIn("成年男性声线", task.request_payload["prompt"]) + def test_character_library_and_temp_are_exclusive(self): portrait = _asset(self.team, self.user, name="模特.png", preview="http://tos/model.png") model = Model.objects.create(team=self.team, name="薇薇", portrait_asset=portrait) diff --git a/core/backend/apps/ai/video_replace.py b/core/backend/apps/ai/video_replace.py index 497e80c..02d9165 100644 --- a/core/backend/apps/ai/video_replace.py +++ b/core/backend/apps/ai/video_replace.py @@ -95,7 +95,9 @@ CHARACTER_PROMPT = ( "【声音重建规则——最高优先级】被替换人物对应的所有声音必须同步替换,包括画面内对白、口播、旁白、画外音、" "语气词和呼吸声。只保留原口播的文字内容、信息含义、句子顺序、出现时间、停顿位置、情绪类型和表达节奏;" "不得保留原人物的声线、音色、音高、年龄感、性别特征、口音、声纹或其他说话人身份特征。" - "根据@目标角色的身份及系统提供的目标声音设定,重新生成全部相关人声。" + "根据@目标角色的形象、下方【目标角色设定】,以及参考图中可见的性别与年龄特征,重新生成全部相关人声。" + "当@目标角色与参考视频中原人物的性别或年龄段不同时,重建人声的性别音色必须随目标角色改变:" + "男性角色必须用男声,女性角色必须用女声,绝不允许沿用原人物的性别音色。" "从视频开始到结束,被替换人物的声音必须始终使用同一个目标声音;不得出现原人物声线残留、声音身份中途变化、" "新旧声音混用,或画面角色已经替换但声音仍属于原人物的情况。" "画面中目标角色的嘴部动作必须与重新生成的人声一致,口型、台词开始时间、停顿、语速和情绪表达保持同步。" @@ -103,6 +105,51 @@ CHARACTER_PROMPT = ( "如保留原视频声音与目标角色声音冲突,必须舍弃原人物声音,以重新生成的目标声音为最高优先级。" ) +# 用户在第 3 步填的补充信息(商品名/品类,或角色性别年龄音色)。选填,不填维持原行为。 +MAX_SUBJECT_BRIEF = 500 + + +def _clean_subject_brief(value) -> str: + return str(value or "").strip()[:MAX_SUBJECT_BRIEF] + + +def _voice_gender_directive(brief: str) -> str: + """从用户填的角色信息里读出性别 → 写死声线要求。 + + Seedance 拿不到明确性别时倾向于沿用原音轨声线,女换男会出现 + 「画面换成男的、声音还是女的」。用户明确写了性别就不再让模型猜。 + 男女都提到(例如「把女的换成男的」)时不下断言,交给上面的通用规则。 + """ + has_male = "男" in brief + has_female = "女" in brief + if has_male and not has_female: + return ( + "【目标声音设定】目标角色为男性:重建的全部人声必须是成年男性声线,中低音域,胸腔共鸣," + "绝不允许出现女性音色、女性音高或偏女的中性声线。" + ) + if has_female and not has_male: + return ( + "【目标声音设定】目标角色为女性:重建的全部人声必须是成年女性声线,中高音域," + "绝不允许出现男性音色或男性音高。" + ) + return "" + + +def build_character_submit_prompt(subject_brief: str = "") -> str: + """角色复刻的提交提示词 = 固定规则 + 用户填的角色设定 + 推导出的声线要求。 + + ⚠️ CHARACTER_PROMPT 必须留在开头:老任务靠 prompt 前缀认 replace_mode。 + """ + brief = _clean_subject_brief(subject_brief) + parts = [CHARACTER_PROMPT] + if brief: + parts.append(f"【目标角色设定】(用户填写,形象与声音的依据){brief}") + voice = _voice_gender_directive(brief) + if voice: + parts.append(voice) + return "\n".join(parts) + + # 拆解期间的占位提示词。真正的提示词在 worker 拆完后回写。 DIGEST_PENDING_PROMPT = "正在提炼参考视频的分镜稿…" @@ -668,6 +715,7 @@ def serialize_video_replace_task(task, *, include_deleted_assets: bool = False) "replace_mode": replace_mode, "subject_name": payload.get("subject_name") or "", "subject_source": payload.get("subject_source") or "", + "subject_brief": payload.get("subject_brief") or "", "product_id": payload.get("product_id") or "", "model_id": payload.get("model_id") or "", "review_stage": "reviewing" if reviewing else "", @@ -726,6 +774,9 @@ def submit_video_replace(*, team, user, params: dict): if video_seconds > REPLACE_REF_DURATION_MAX: raise ValueError("参考视频不能超过 30 秒,请剪短后重试") + # 第 3 步用户填的补充信息。商品:名称/品类/卖点;角色:性别/年龄/音色。选填。 + subject_brief = _clean_subject_brief(params.get("subject_brief")) + if has_product: subject_name, image_refs, subject_source = _product_library_refs(team, product_id) product_facts = _product_semantic_facts_for_id(team, product_id) @@ -744,6 +795,17 @@ def submit_video_replace(*, team, user, params: dict): "禁止推断": "不得编造功效、成分、认证和任何具体卖点;口播只描述看得见的外观、质地和使用感受。", } + # 用户手填的商品信息优先级最高:临时上传时这往往是唯一的品类来源, + # 商品库选品时它补充库里没填的字段。填了就不再是「仅外观」。 + if replace_mode == "product" and subject_brief: + product_facts = { + **product_facts, + "用户补充信息": subject_brief, + "用户补充信息的权威性": "这是用户手动填写的商品事实,可直接作为品类、用途和卖点的依据,优先级高于据图推断。", + } + if product_facts.get("资料状态") == "仅外观": + product_facts["资料状态"] = "部分" + # 角色替换是 Seedance 视频编辑:火山要求比例/时长跟随输入视频,不能由前端指定。 duration = -1 if replace_mode == "character" else _output_duration(params.get("duration"), video_seconds) billing_ratio = str(params.get("aspect_ratio") or "9:16") @@ -756,6 +818,7 @@ def submit_video_replace(*, team, user, params: dict): "product_id": str(product_id) if product_id else "", "model_id": str(model_id) if model_id else "", "product_facts": product_facts, + "subject_brief": subject_brief, # adaptive / -1 只用于火山请求;预估积分仍按参考视频实际时长和上传时检测出的比例计算。 "billing_duration": max(4, int(round(video_seconds or 4))), "billing_aspect_ratio": billing_ratio, @@ -802,7 +865,10 @@ def submit_video_replace(*, team, user, params: dict): user=user, params={ **base_params, - "prompt": DIGEST_PENDING_PROMPT if replace_mode == "product" else CHARACTER_PROMPT, + "prompt": ( + DIGEST_PENDING_PROMPT if replace_mode == "product" + else build_character_submit_prompt(subject_brief) + ), "references": image_refs, "extra_payload": extra, }, diff --git a/core/frontend/src/api.ts b/core/frontend/src/api.ts index 618d000..82a50da 100644 --- a/core/frontend/src/api.ts +++ b/core/frontend/src/api.ts @@ -912,6 +912,7 @@ export const api = { product_id?: string; model_id?: string; image_asset_ids?: string[]; + subject_brief?: string; model?: string; aspect_ratio?: string; resolution?: string; diff --git a/core/frontend/src/routes/video-replace.tsx b/core/frontend/src/routes/video-replace.tsx index 93af8b9..f04f536 100644 --- a/core/frontend/src/routes/video-replace.tsx +++ b/core/frontend/src/routes/video-replace.tsx @@ -43,6 +43,8 @@ const PRODUCT_SOURCE_PURPOSE = "video_replace_product"; const REF_SECONDS_MAX = { product: 30, character: 30 } as const; const REF_BYTES_MAX = { product: 200 * 1024 * 1024, character: 200 * 1024 * 1024 } as const; const SEEDANCE_MAX_OUTPUT_SECONDS = 30; +// 第 3 步补充信息上限,与后端 MAX_SUBJECT_BRIEF 保持一致 +const SUBJECT_BRIEF_MAX = 500; type ProductSource = "library" | "temporary" | ""; type ReplaceMode = "product" | "character"; @@ -74,6 +76,10 @@ const REPLACE_MODE_COPY = { drawerEmpty: "还没有商品,先去商品库创建一个", historyKind: "商品", pickToast: "已选择商品", + briefStep: "3. 商品信息", + briefOptional: "选填 · 填了更准", + briefPlaceholder: "例:素颜霜,膏体按压泵,主打提亮和保湿,适合日常通勤", + briefHint: "填写商品名称、品类和卖点后,口播和使用动作会按真实用途来写;不填则只按参考图的外观拍。", }, character: { modeLabel: "角色复刻", @@ -101,6 +107,10 @@ const REPLACE_MODE_COPY = { drawerEmpty: "还没有人物,先去模特库添加", historyKind: "角色", pickToast: "已选择角色", + briefStep: "3. 角色信息", + briefOptional: "选填 · 建议写明性别", + briefPlaceholder: "例:男性,30 岁左右,中低音域,语速平稳,声音干净有亲和力", + briefHint: "配音会按这里的设定重新生成声线,建议写明男 / 女,避免沿用原视频人物的音色。", }, } as const; @@ -284,6 +294,7 @@ interface ModeFormState { tempFiles: File[]; tempAssetRefs: FreeVideoRef[]; filledSubjectName: string; + subjectBrief: string; job: FreeVideoTask | null; jobId: string; } @@ -300,6 +311,7 @@ const createInitialModeState = (): ModeFormState => ({ tempFiles: [], tempAssetRefs: [], filledSubjectName: "", + subjectBrief: "", job: null, jobId: "", }); @@ -357,6 +369,7 @@ export function VideoReplacePage({ tempFiles, tempAssetRefs, filledSubjectName, + subjectBrief, job, jobId, } = currentForm; @@ -824,6 +837,7 @@ export function VideoReplacePage({ product_id: current.source === "library" && replaceMode === "product" ? current.selectedProduct?.id : undefined, model_id: current.source === "library" && replaceMode === "character" ? current.selectedModel?.id : undefined, image_asset_ids: current.source === "temporary" ? imageAssetIds : undefined, + subject_brief: current.subjectBrief.trim() || undefined, model: preferredModel.name, aspect_ratio: aspectRatio, resolution: "720p", @@ -902,6 +916,7 @@ export function VideoReplacePage({ ...sizeFromRatio(task.aspect_ratio || "9:16"), }, filledSubjectName: subject, + subjectBrief: task.subject_brief || "", selectedModel: model || null, selectedProduct: null, source: model ? "library" : (images.length ? "temporary" : ""), @@ -926,6 +941,7 @@ export function VideoReplacePage({ ...sizeFromRatio(task.aspect_ratio || "9:16"), }, filledSubjectName: subject, + subjectBrief: task.subject_brief || "", selectedProduct: product || null, selectedModel: null, source: product ? "library" : (images.length ? "temporary" : ""), @@ -1224,6 +1240,23 @@ export function VideoReplacePage({ +
+
+ {copy.briefStep} + {copy.briefOptional} +
+