优化替换角色添加音频

This commit is contained in:
Azmat@qq.com
2026-09-01 17:55:57 +08:00
parent a520b230c5
commit 0603a85d83
10 changed files with 322 additions and 111 deletions
+98 -69
View File
@@ -55,8 +55,6 @@ REPLACE_REF_DURATION_MAX = 30.5
REVIEW_UNAVAILABLE = "素材审核服务暂不可用,请稍后重试"
REVIEW_FAILED = "参考素材未通过真人合规审核,请更换视频或图片后重试"
REVIEW_SUBMIT_FAILED = "素材提交审核失败,请稍后重试"
class VideoReplaceInProgress(ValueError):
"""本团队已有复刻在跑。视图转 409,并把在跑的那条带回去让前端接上。"""
@@ -85,26 +83,55 @@ PRODUCT_PROMPT = (
"按下面的商品语义重构分镜生成视频。@目标商品只用于锁定外观、材质与包装。"
"严格遵守每镜的新商品动作、场景与口播;不要恢复参考片原商品、原动作、原卖点或原台词。"
)
# 角色复刻提示词:极简单句。之前那版把声音规则写成五大段反复强调,实测是「开头原声、
# 中途才切到目标音色」;长提示词会稀释关键指令,这版改为一句话直给。
# ⚠️ 句中的 @参考角色 / @参考音频 必须与 references 的 label 完全一致,否则
# build_content_items 映射不成「图片N / 音频N」,火山那边就是一串没有指代的字面量。
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",
}
# 用户在第 3 步填的补充信息(商品名/品类,或角色性别年龄音色)。选填,不填维持原行为。
MAX_SUBJECT_BRIEF = 500
@@ -113,43 +140,20 @@ def _clean_subject_brief(value) -> str:
return str(value or "").strip()[:MAX_SUBJECT_BRIEF]
def _voice_gender_directive(brief: str) -> str:
"""从用户填的角色信息里读性别 → 写死声线要求
def _detect_voice_gender(brief: str) -> str:
"""从用户填的角色信息里读性别。返回 male / female / ""(读不出)
Seedance 拿不到明确性别时倾向于沿用原音轨声线,女换男会出现
「画面换成男的、声音还是女的」。用户明确写了性别就不再让模型猜。
男女都提到(例如「把女的换成男的」)时不下断言,交给上面的通用规则。
男女都提到(例如「把女的换成男的」)时不下断言 —— 分不清哪个是目标。
"""
has_male = "" in brief
has_female = "" in brief
if has_male and not has_female:
return (
"【目标声音设定】目标角色为男性:重建的全部人声必须是成年男性声线,中低音域,胸腔共鸣,"
"绝不允许出现女性音色、女性音高或偏女的中性声线。"
)
return "male"
if has_female and not has_male:
return (
"【目标声音设定】目标角色为女性:重建的全部人声必须是成年女性声线,中高音域,"
"绝不允许出现男性音色或男性音高。"
)
return "female"
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 = "正在提炼参考视频的分镜稿…"
@@ -693,6 +697,16 @@ def video_replace_q() -> Q:
return Q(request_payload__feature=FEATURE) | Q(request_payload__prompt__startswith=LEGACY_PROMPT_PREFIX)
def _fresh_digest_source(payload: dict) -> dict | None:
"""历史卡「原视频」用的原片快照。快照 URL 会过期,统一走 rehydrate_ref_urls 重新取长期直链。"""
from .free_video import rehydrate_ref_urls
ref = payload.get("digest_source_ref") or None
if not isinstance(ref, dict):
return None
return rehydrate_ref_urls([ref])[0]
def serialize_video_replace_task(task, *, include_deleted_assets: bool = False) -> dict:
data = serialize_free_video_task(task, include_deleted_assets=include_deleted_assets)
payload = task.request_payload or {}
@@ -716,6 +730,7 @@ 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 "",
@@ -723,7 +738,8 @@ def serialize_video_replace_task(task, *, include_deleted_assets: bool = False)
"digest_text": str(payload.get("digest_text") or ""),
"digest_shots": payload.get("digest_shots") or shot_total or 0,
"digest_source_name": payload.get("digest_source_name") or "",
"digest_source": payload.get("digest_source_ref") or None,
# 原片快照存的是提交当时的直链,历史卡要隔天还能播 → 按 asset_id 重新取
"digest_source": _fresh_digest_source(payload),
"shot_index": shot_index,
"shot_total": shot_total,
})
@@ -776,6 +792,8 @@ 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)
@@ -786,14 +804,18 @@ def submit_video_replace(*, team, user, params: dict):
else:
noun = "商品" if replace_mode == "product" else "角色"
subject_name, image_refs, subject_source = _temporary_image_refs(team, image_ids, noun=noun)
product_facts = {
"商品名称": subject_name,
"资料状态": "仅外观",
"资料状态的含义": "该状态只约束口播能说什么(不得宣称功效、成分和卖点);不约束画面能拍什么动作。",
"形态判定": "商品的形态、部件、开合方式和使用接口以临时上传的参考图为准,可直接据图判断",
"动作策略": "同族继承优先:参考图中的商品与参考片原商品属于同一形态族或品类族时,逐镜沿用原片的取出、开启、使用、作用部位等动作,只替换商品外观。仅当形态确实不兼容时,才按参考图形态重新设计动作",
"禁止推断": "不得编造功效、成分、认证和任何具体卖点;口播只描述看得见的外观、质地和使用感受",
}
product_facts = (
{
"商品名称": subject_name,
"资料状态": "仅外观",
"资料状态的含义": "该状态只约束口播能说什么(不得宣称功效、成分和卖点);不约束画面能拍什么动作",
"形态判定": "商品的形态、部件、开合方式和使用接口以临时上传的参考图为准,可直接据图判断",
"动作策略": "同族继承优先:参考图中的商品与参考片原商品属于同一形态族或品类族时,逐镜沿用原片的取出、开启、使用、作用部位等动作,只替换商品外观。仅当形态确实不兼容时,才按参考图形态重新设计动作",
"禁止推断": "不得编造功效、成分、认证和任何具体卖点;口播只描述看得见的外观、质地和使用感受。",
}
if replace_mode == "product"
else {}
)
# 用户手填的商品信息优先级最高:临时上传时这往往是唯一的品类来源,
# 商品库选品时它补充库里没填的字段。填了就不再是「仅外观」。
@@ -819,6 +841,8 @@ 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,
@@ -838,14 +862,18 @@ def submit_video_replace(*, team, user, params: dict):
# 商品复刻需要 Gemini 拆镜后重构商品语义;角色复刻直接交给 Seedance 2.5
# 原视频与角色图都先进入同一火山素材库审核,再以 asset:// 作为参考。
if replace_mode == "product":
# 商品复刻靠拆镜重构语义。
from .video_digest import resolve_digest_model_config
if resolve_digest_model_config() is None:
raise ValueError("视频提炼模型未配置,请联系管理员")
else:
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)
@@ -867,7 +895,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)
else build_character_submit_prompt(subject_brief, has_voice_ref=bool(voice_gender))
),
"references": image_refs,
"extra_payload": extra,
@@ -888,7 +916,6 @@ def advance_video_replace(task):
payload = task.request_payload or {}
if payload.get("digest_pending"):
# worker 还在拆参考视频,提示词都没生成,别当成「审核中」反复送审。
# worker 挂了也不会永远卡着:CREATED 超 16 分钟由 _reap_stale_free_video_tasks 回收退费。
return task
references = (
_image_refs(payload.get("references") or [])
@@ -944,9 +971,6 @@ def run_replace_digest(task) -> None:
if not is_video_replace_task(task) or task.status != AITask.Status.CREATED:
return
payload = task.request_payload or {}
# 角色复刻没有 Gemini 拆镜环节;审核通过后由 advance_video_replace 直接提交 Seedance。
if payload.get("replace_mode") == "character":
return
if not payload.get("digest_pending"):
return
@@ -969,8 +993,10 @@ def run_replace_digest(task) -> None:
try:
digest, meta = digest_asset_video(asset=asset, task=task)
if replace_mode == "character":
prompt = build_character_replace_prompt(
digest, source_seconds=source_seconds, output_seconds=output_seconds,
# 角色复刻不把分镜稿塞进提示词,保持已验证的编辑画面效果。
# 编辑模式的画面效果是用户认可的,提示词一动画面就可能跟着变。
prompt = str(payload.get("prompt") or "") or build_character_submit_prompt(
str(payload.get("subject_brief") or "")
)
else:
prompt = rewrite_product_semantic_remix(
@@ -1857,9 +1883,10 @@ def _output_duration(requested, video_seconds: float) -> int:
def _owned_ref(asset: Asset, *, kind: str, role: str, label: str) -> dict:
from .services import _asset_preview_url
# 快照要长期可播(历史卡「原视频」),所以取不过期的公读直链,不用 1 小时预签名。
from .services import asset_stable_url
url = _asset_preview_url(asset)
url, thumb = asset_stable_url(asset)
if not url:
raise ValueError(f"{label}」没有可用文件")
ref = {
@@ -1870,6 +1897,8 @@ def _owned_ref(asset: Asset, *, kind: str, role: str, label: str) -> dict:
"source": "asset",
"asset_id": str(asset.id),
}
if thumb:
ref["thumb_url"] = thumb
seconds = _asset_duration_seconds(asset)
if seconds:
ref["duration"] = seconds
@@ -1999,8 +2028,8 @@ def _character_library_refs(team, model_id: uuid.UUID) -> tuple[str, list, str]:
break
if not assets:
raise ValueError("这个角色还没有可用图片")
labels = ["目标角色", "目标角色三视图"]
refs = [_library_image_ref(asset, team=team, label=labels[index] if index < len(labels) else f"目标角色{index + 1}") for index, asset in enumerate(assets)]
labels = ["参考角色", "参考角色三视图"]
refs = [_library_image_ref(asset, team=team, label=labels[index] if index < len(labels) else f"参考角色{index + 1}") for index, asset in enumerate(assets)]
return model.name, refs, "library"
@@ -2008,7 +2037,7 @@ def _temporary_image_refs(team, image_ids: list, *, noun: str) -> tuple[str, lis
refs = []
for index, asset_id in enumerate(image_ids):
asset = _team_asset(team, asset_id, kind=Asset.Type.IMAGE, label=f"{noun}参考图")
label = "目标商品" if noun == "商品" else "目标角色"
label = "目标商品" if noun == "商品" else "参考角色"
if index > 0:
label = f"{label}{index + 1}"
refs.append(_owned_ref(asset, kind="image", role="reference_image", label=label))