diff --git a/core/backend/apps/ai/test_video_replace.py b/core/backend/apps/ai/test_video_replace.py index 22d0716..6a84a81 100644 --- a/core/backend/apps/ai/test_video_replace.py +++ b/core/backend/apps/ai/test_video_replace.py @@ -729,19 +729,14 @@ class SubmitVideoReplaceTests(TestCase): """ from apps.ai.video_replace import build_character_submit_prompt - with_voice = build_character_submit_prompt("男性,30 岁左右,中低音域", has_voice_ref=True) - self.assertIn("@参考角色", with_voice) - self.assertIn("@参考音频", with_voice) - self.assertIn("@参考视频", with_voice) - # brief 不进提示词 - self.assertNotIn("30 岁", with_voice) + prompt = build_character_submit_prompt() + self.assertIn("@参考角色", prompt) + self.assertIn("@参考视频", prompt) + # 只换画面,不碰声音 + self.assertNotIn("@参考音频", prompt) + self.assertNotIn("音色", prompt) # 一句话:不该有分段 - self.assertNotIn("\n", with_voice) - - # 没有音色样音时不能点名一个不存在的 @参考音频 - no_voice = build_character_submit_prompt("短发", has_voice_ref=False) - self.assertIn("@参考角色", no_voice) - self.assertNotIn("@参考音频", no_voice) + self.assertNotIn("\n", prompt) def test_character_brief_lands_in_submitted_prompt(self): portrait = _asset(self.team, self.user, name="模特.png", preview="http://tos/model.png") @@ -753,25 +748,21 @@ class SubmitVideoReplaceTests(TestCase): self.assertEqual(task.request_payload["subject_brief"], "男性,稳重,中低音域") self.assertNotIn("声音重建规则", task.request_payload["prompt"]) - def test_character_gender_attaches_platform_voice_reference(self): - task = self._submit_character(subject_brief="男性,稳重,中低音域") - self.assertEqual(task.request_payload["voice_gender"], "male") - self.assertEqual(task.request_payload["voice_strategy"], "native_audio_reference") - # 平台预置音色样音要跟着一起发给 Seedance,并在提示词里被点名 - audio = [r for r in task.request_payload["references"] if r.get("type") == "audio"] - self.assertEqual(len(audio), 1) - self.assertEqual(audio[0]["label"], "参考音频") - self.assertEqual(audio[0]["role"], "reference_audio") - self.assertNotIn("asset_id", audio[0], "平台音频不能带 asset_id,否则会被送去真人素材审核卡死") - self.assertIn("@参考音频", task.request_payload["prompt"]) - self.assertFalse(task.request_payload["digest_pending"], "音频参考路线不需要拆镜") - - # 没写明性别就不该带音频参考,也不该出现声音段落 - plain = self._submit_character(subject_brief="短发,深色衬衫") - self.assertEqual(plain.request_payload["voice_strategy"], "preserve_original") - self.assertNotIn("reference_audio", [r.get("role") for r in plain.request_payload["references"]]) - self.assertNotIn("@参考音频", plain.request_payload["prompt"]) + def test_character_keeps_original_audio(self): + """角色复刻只换画面,声音保留原声。 + 历史:试过四条让 Seedance 换声的路(提示词声线规则、音频参考走直链、走 asset://、 + 平台预置样音),最好也只做到「中途才切到目标音色」。产品上已收敛为同性别替换 + + 保留原声,前端明确告知用户 —— 别再往提示词或 references 里加音频。 + """ + task = self._submit_character(subject_brief="30 岁男性,短发") + payload = task.request_payload + self.assertNotIn("reference_audio", [r.get("role") for r in payload["references"]]) + self.assertNotIn("audio", [r.get("type") for r in payload["references"]]) + self.assertNotIn("@参考音频", payload["prompt"]) + self.assertNotIn("音色", payload["prompt"]) + self.assertNotIn("voice_gender", payload) + self.assertNotIn("voice_strategy", payload) def test_character_library_and_temp_are_exclusive(self): portrait = _asset(self.team, self.user, name="模特.png", preview="http://tos/model.png") diff --git a/core/backend/apps/ai/video_replace.py b/core/backend/apps/ai/video_replace.py index 14d3e83..670a5b7 100644 --- a/core/backend/apps/ai/video_replace.py +++ b/core/backend/apps/ai/video_replace.py @@ -83,54 +83,25 @@ PRODUCT_PROMPT = ( "按下面的商品语义重构分镜生成视频。@目标商品只用于锁定外观、材质与包装。" "严格遵守每镜的新商品动作、场景与口播;不要恢复参考片原商品、原动作、原卖点或原台词。" ) -# 角色复刻提示词:极简单句。之前那版把声音规则写成五大段反复强调,实测是「开头原声、 -# 中途才切到目标音色」;长提示词会稀释关键指令,这版改为一句话直给。 -# ⚠️ 句中的 @参考角色 / @参考音频 必须与 references 的 label 完全一致,否则 -# build_content_items 映射不成「图片N / 音频N」,火山那边就是一串没有指代的字面量。 +# 角色复刻提示词:极简单句。 +# ⚠️ 只换画面,不碰声音 —— 成片保留参考视频原声。 +# 历史(别再走一遍):试过让 Seedance 换声的四条路,提示词声线规则、音频参考走 TOS 直链、 +# 走 asset:// 素材库引用、以及平台预置样音,全部只做到「中途才切到目标音色」或完全无效。 +# 编辑模式(参考视频 + duration=-1 + adaptive)对音轨的处理不受这些输入控制。 +# 因此产品上收敛为:只支持同性别角色替换,声音一律保留原声,前端明确告知用户。 CHARACTER_PROMPT = ( - "将@参考视频中出现的角色替换为@参考角色,口播、旁白、画外音的音色替换为@参考音频," - "其它部分例如构图、运镜、人物动作、场景、商品、剪辑、画面节奏等保持与原视频一致。" -) - -# 没有音频参考(没写明性别)时,把声音那半句去掉,免得点名一个不存在的 @参考音频。 -CHARACTER_PROMPT_NO_VOICE = ( "将@参考视频中出现的角色替换为@参考角色," "其它部分例如构图、运镜、人物动作、场景、商品、剪辑、画面节奏等保持与原视频一致。" ) def build_character_submit_prompt(subject_brief: str = "", *, has_voice_ref: bool = False) -> str: - """角色复刻提交提示词。就是那一句,不再追加角色设定 —— - subject_brief 只用于推导性别(决定用哪条音色样音),不进提示词,保持指令干净。""" - return CHARACTER_PROMPT if has_voice_ref else CHARACTER_PROMPT_NO_VOICE + """角色复刻提交提示词。就是那一句。 - -# 平台预置的目标音色样音(公读直链,不走预签名 —— 参考素材要长期可取)。 -VOICE_REFERENCE_LABEL = "参考音频" -VOICE_REFERENCE_URLS = { - # ⚠️ 换音色样音时务必换文件名,不要覆盖旧文件:上传设的是 immutable + 一年强缓存, - # 同名覆盖后 CDN / 火山侧可能仍拿到旧音频,查起来很难受。 - "male": "https://airshelf.tos-s3-cn-shanghai.volces.com/system/video-replace/voice-references/male-v3.mp3", - "female": "https://airshelf.tos-s3-cn-shanghai.volces.com/system/video-replace/voice-references/female-bv001.mp3", -} - - -def build_voice_reference_ref(gender: str) -> dict | None: - """平台预置音色样音的 reference 项。读不出性别就没有音频参考。 - - ⚠️ 不带 asset_id:带了会被 _ensure_replace_refs_reviewed 送去火山真人素材审核, - 而那套是给图片/视频用的,平台音频过不去会把任务卡死。 + 参数保留是为了兼容既有调用点;声音已不再由提示词控制,两个参数都不影响输出。 """ - url = VOICE_REFERENCE_URLS.get(gender) - if not url: - return None - return { - "url": url, - "type": "audio", - "role": "reference_audio", - "label": VOICE_REFERENCE_LABEL, - "source": "platform", - } + return CHARACTER_PROMPT + # 用户在第 3 步填的补充信息(商品名/品类,或角色性别年龄音色)。选填,不填维持原行为。 MAX_SUBJECT_BRIEF = 500 @@ -140,20 +111,6 @@ def _clean_subject_brief(value) -> str: return str(value or "").strip()[:MAX_SUBJECT_BRIEF] -def _detect_voice_gender(brief: str) -> str: - """从用户填的角色信息里读性别。返回 male / female / ""(读不出)。 - - 男女都提到(例如「把女的换成男的」)时不下断言 —— 分不清哪个是目标。 - """ - has_male = "男" in brief - has_female = "女" in brief - if has_male and not has_female: - return "male" - if has_female and not has_male: - return "female" - return "" - - # 拆解期间的占位提示词。真正的提示词在 worker 拆完后回写。 DIGEST_PENDING_PROMPT = "正在提炼参考视频的分镜稿…" @@ -730,7 +687,6 @@ def serialize_video_replace_task(task, *, include_deleted_assets: bool = False) "subject_name": payload.get("subject_name") or "", "subject_source": payload.get("subject_source") or "", "subject_brief": payload.get("subject_brief") or "", - "voice_gender": payload.get("voice_gender") or "", "product_id": payload.get("product_id") or "", "model_id": payload.get("model_id") or "", "review_stage": "reviewing" if reviewing else "", @@ -793,7 +749,6 @@ def submit_video_replace(*, team, user, params: dict): # 第 3 步用户填的补充信息。商品:名称/品类/卖点;角色:性别/年龄/音色。选填。 subject_brief = _clean_subject_brief(params.get("subject_brief")) # 角色描述识别到性别时,成片后调用火山音色转换;识别不到即保留原声。 - voice_gender = _detect_voice_gender(subject_brief) if replace_mode == "character" else "" if has_product: subject_name, image_refs, subject_source = _product_library_refs(team, product_id) @@ -841,8 +796,6 @@ def submit_video_replace(*, team, user, params: dict): "model_id": str(model_id) if model_id else "", "product_facts": product_facts, "subject_brief": subject_brief, - "voice_gender": voice_gender, - "voice_strategy": "native_audio_reference" if voice_gender else "preserve_original", # adaptive / -1 只用于火山请求;预估积分仍按参考视频实际时长和上传时检测出的比例计算。 "billing_duration": max(4, int(round(video_seconds or 4))), "billing_aspect_ratio": billing_ratio, @@ -868,12 +821,9 @@ def submit_video_replace(*, team, user, params: dict): if resolve_digest_model_config() is None: raise ValueError("视频提炼模型未配置,请联系管理员") if replace_mode == "character": - # 平台预置的目标音色样音,和参考视频、角色图一起交给 Seedance。 - voice_ref = build_voice_reference_ref(voice_gender) image_refs = [ _owned_ref(video, kind="video", role="reference_video", label="参考视频"), *image_refs, - *([voice_ref] if voice_ref else []), ] # 商品图/角色图仍要过审才能当生成参考。提前送审 → 审核和提炼并行跑,明确不过审的直接 400。 review_state = _ensure_replace_refs_reviewed(team, image_refs) @@ -895,7 +845,7 @@ def submit_video_replace(*, team, user, params: dict): **base_params, "prompt": ( DIGEST_PENDING_PROMPT if replace_mode == "product" - else build_character_submit_prompt(subject_brief, has_voice_ref=bool(voice_gender)) + else build_character_submit_prompt() ), "references": image_refs, "extra_payload": extra, diff --git a/core/frontend/src/routes/video-replace.tsx b/core/frontend/src/routes/video-replace.tsx index 4ac09d7..7847c6a 100644 --- a/core/frontend/src/routes/video-replace.tsx +++ b/core/frontend/src/routes/video-replace.tsx @@ -106,10 +106,6 @@ const REPLACE_MODE_COPY = { drawerEmpty: "还没有人物,先去模特库添加", historyKind: "角色", pickToast: "已选择角色", - briefStep: "3. 角色信息", - briefOptional: "选填", - briefPlaceholder: "例:30 岁左右男性,短发,深色衬衫,气质干练利落", - briefHint: "写明目标角色是男或女时,系统会自动使用对应的通用声音参考;未写明则保留参考视频原声。", }, } as const; @@ -1239,22 +1235,30 @@ export function VideoReplacePage({ -