优化替换角色添加音频
This commit is contained in:
@@ -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))
|
||||
|
||||
Reference in New Issue
Block a user