优化完善替换角色

This commit is contained in:
Azmat@qq.com
2026-09-01 18:29:36 +08:00
parent 0603a85d83
commit 1825543873
4 changed files with 64 additions and 121 deletions
+21 -30
View File
@@ -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")
+11 -61
View File
@@ -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,
+23 -19
View File
@@ -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({
</div>
</div>
<div className="video-flow-step">
<div className="video-flow-step-head">
<strong>{copy.briefStep}</strong>
<span>{copy.briefOptional}</span>
{replaceMode === "product" ? (
<div className="video-flow-step">
<div className="video-flow-step-head">
<strong>{REPLACE_MODE_COPY.product.briefStep}</strong>
<span>{REPLACE_MODE_COPY.product.briefOptional}</span>
</div>
<textarea
className="textarea replace-brief-input"
value={subjectBrief}
maxLength={SUBJECT_BRIEF_MAX}
placeholder={REPLACE_MODE_COPY.product.briefPlaceholder}
disabled={generating}
aria-label={REPLACE_MODE_COPY.product.briefStep}
onChange={(event) => updateCurrentForm({ subjectBrief: event.target.value })}
/>
<p className="field-hint replace-brief-hint">{REPLACE_MODE_COPY.product.briefHint}</p>
</div>
<textarea
className="textarea replace-brief-input"
value={subjectBrief}
maxLength={SUBJECT_BRIEF_MAX}
placeholder={copy.briefPlaceholder}
disabled={generating}
aria-label={copy.briefStep}
onChange={(event) => updateCurrentForm({ subjectBrief: event.target.value })}
/>
<p className="field-hint replace-brief-hint">{copy.briefHint}</p>
</div>
) : (
<div className="tip replace-limit-tip" role="note">
<strong></strong>
</div>
)}
<div className="video-flow-actions replace-generate-action">
<button
+9 -11
View File
@@ -204,19 +204,17 @@
}
/* 第 3 步补充信息。输入框本体复用共享 .textarea,这里只给提示文案留出间距。 */
.vrep-page .replace-voice-row {
display: flex;
align-items: center;
flex-wrap: wrap;
gap: 8px;
margin-top: 10px;
/* 使用限制提示。复用共享 .tip,这里只把配色换成警示色(用现有 token,不新建色值)。 */
.vrep-page .replace-limit-tip {
margin-top: 22px;
background: var(--honey-bg);
border-style: solid;
border-color: var(--honey-bd);
color: var(--black-alpha-72);
}
.vrep-page .replace-voice-label {
font-size: 13px;
font-weight: 500;
color: var(--accent-black);
margin-right: 2px;
.vrep-page .replace-limit-tip strong {
color: var(--accent-honey);
}
.vrep-page .replace-brief-hint {