优化替换角色添加音频

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
+32 -1
View File
@@ -1048,6 +1048,37 @@ def finalize_free_video(*, task: AITask) -> AITask:
return locked
def rehydrate_ref_urls(references) -> list:
"""参考素材快照里的 URL 是提交当时签的 TOS 预签名链(1 小时过期),历史记录隔天再点开就播不了。
凡是带 asset_id 的,按当前资产重新取长期直链(顺带补上视频封面),老任务也一起救回来。
素材库引用(source=library)的 asset_id 指向 FreeAsset 不是 Asset,查不到就原样保留快照。"""
refs = [item for item in (references or [])]
ids = set()
for item in refs:
if not isinstance(item, dict) or item.get("source") == "library":
continue
if item.get("asset_id"):
try:
ids.add(uuid.UUID(str(item["asset_id"])))
except (ValueError, TypeError, AttributeError):
continue
if not ids:
return refs
from .services import asset_stable_url
assets = {str(a.id): a for a in Asset.objects.filter(id__in=ids).prefetch_related("files")}
fresh = []
for item in refs:
asset = assets.get(str(item.get("asset_id") or "")) if isinstance(item, dict) else None
url, thumb = asset_stable_url(asset) if asset is not None else ("", "")
if not url:
fresh.append(item)
continue
fresh.append({**item, "url": url, **({"thumb_url": thumb} if thumb else {})})
return fresh
def serialize_free_video_task(task: AITask, *, include_deleted_assets: bool = False) -> dict:
"""任务 → 前端契约。视频/封面直链从 generated_assets 取(TOS 公读),转存失败回落火山临时 URL。"""
payload = task.request_payload or {}
@@ -1097,7 +1128,7 @@ def serialize_free_video_task(task: AITask, *, include_deleted_assets: bool = Fa
"seed": payload.get("seed", -1),
"seed_used": payload.get("seed_used"),
"generate_audio": payload.get("generate_audio", True),
"references": payload.get("references") or [],
"references": rehydrate_ref_urls(payload.get("references")),
"estimated_tokens": payload.get("estimated_tokens") or 0,
"actual_tokens": payload.get("actual_tokens") or 0,
"estimated_cost": str(task.estimated_cost),
+6 -6
View File
@@ -132,11 +132,11 @@ PERSONA_BRIEFS: dict[str, str] = {
}
_PERSONA_KEY_BY_LABEL = {label: key for key, label in PERSONA_LABELS.items()}
# 电商口播需要同时承载观点、证据和转折;15 秒以 4.35.2 字/秒为可用区间。
# 这比日常闲聊快,但仍给句间停顿和镜头切换留空间。
NARRATION_CHARS_PER_SECOND = 5.2
NARRATION_CHARS_PER_SECOND_MIN = 4.3
NARRATION_CHARS_HARD_CAP = 78
# 电商口播需要同时承载观点、证据和转折;15 秒固定以 5.05.7 字/秒为目标区间。
# 用户希望旁白更完整,因此每镜目标为 75–85 字;仍以短句切分,给镜头切换留空间。
NARRATION_CHARS_PER_SECOND = 5.7
NARRATION_CHARS_PER_SECOND_MIN = 5.0
NARRATION_CHARS_HARD_CAP = 85
# 故事板下线后,visual 是出片模型唯一的画面依据,门槛从「够写一句」抬到「够当导演说明书」
VISUAL_CHARS_MIN = 110
SHOT_BEATS_MIN = 3
@@ -291,7 +291,7 @@ def recommend_script_setup(category: str = "", title: str = "") -> dict:
def narration_limit(duration: int) -> int:
"""这一镜旁白的字数上限:秒数 × 5.2,且不超过硬上限 78。"""
"""这一镜旁白的字数上限:秒数 × 5.7,且不超过硬上限 85"""
return max(1, min(NARRATION_CHARS_HARD_CAP, int(duration * NARRATION_CHARS_PER_SECOND)))
+36
View File
@@ -2917,6 +2917,42 @@ def _asset_preview_url(asset) -> str:
return ""
def asset_stable_url(asset) -> tuple[str, str]:
"""资产的「长期可打开」直链 (主文件, 封面图)。历史记录这类隔天还要点开的场景必须用这个:
预签名链只活 1 小时,存进 payload 快照的话第二天就播不了 —— 公读直链不过期还能被缓存。
顺序:落库 preview_url → TOS 公读直链 → 兜底预签名。"""
if asset is None:
return "", ""
files = list(asset.files.all())
if not files:
return "", ""
primary = next((f for f in files if f.is_primary), None) or files[0]
# 封面只对视频/音频有意义:图片资产的第二张文件是它自己的兄弟图,不是封面。
poster = None
if "image" not in (primary.content_type or ""):
poster = next(
(f for f in files if f is not primary and "image" in (f.content_type or "")), None
)
def _url(f) -> str:
if f is None:
return ""
if f.preview_url:
return f.preview_url
if not f.object_key:
return ""
try:
return TosStorage().public_url(object_key=f.object_key, bucket=f.bucket or None)
except Exception: # noqa: BLE001
pass
try:
return TosStorage().presigned_get_url(object_key=f.object_key)
except Exception: # noqa: BLE001
return ""
return _url(primary), _url(poster)
def _seedance_ref_url(raw_url: str, review_status: str = "", review_remote_id: str = "") -> str:
"""Seedance 参考图 URL:只要资产已进素材库(有 remote_id)就用 asset:// 素材库引用 —— 火山认的是
「在不在素材库」,与审核态无关(实测 processing 也能引用);未进库才回落原始直链。
@@ -424,15 +424,15 @@ class PromptAssemblyTests(SimpleTestCase):
class NarrationLimitTests(SimpleTestCase):
def test_limit_scales_with_shot_length(self):
self.assertEqual(narration_limit(4), 20)
self.assertEqual(narration_limit(8), 41)
self.assertEqual(narration_limit(15), 78)
self.assertEqual(narration_limit(4), 22)
self.assertEqual(narration_limit(8), 45)
self.assertEqual(narration_limit(15), 85)
def test_never_exceeds_hard_cap(self):
self.assertLessEqual(narration_limit(60), 78)
self.assertLessEqual(narration_limit(60), 85)
def test_fifteen_second_floor_is_about_sixty_four(self):
self.assertEqual(narration_floor(15), 64)
self.assertEqual(narration_floor(15), 75)
self.assertLess(narration_floor(15), narration_limit(15))
@@ -461,7 +461,7 @@ class ShotDensityTests(SimpleTestCase):
self.assertIn("旁白太短", str(ctx.exception))
def test_oral_meeting_floor_passes(self):
narration = "下午三点工位犯困,键盘都敲不利索。我以前总是硬扛,越扛脑子越乱。后来会先倒杯热茶,第一口是回甘不是苦,桌上的文件终于能看进去,整个人才慢慢醒过来。"
narration = "下午三点工位犯困,键盘都敲不利索。我以前总是硬扛,越扛脑子越乱。后来会先倒杯热茶,第一口是回甘不是苦,桌上的文件终于能看进去,整个人才慢慢醒过来,状态也稳下来"
self.assertGreaterEqual(len(narration.replace(" ", "")), narration_floor(15))
self.assertGreaterEqual(len(self._VISUAL.replace(" ", "")), VISUAL_CHARS_MIN)
assert_shot_density(
+122 -25
View File
@@ -204,6 +204,17 @@ class SubmitVideoReplaceTests(TestCase):
params.update(over)
return submit_video_replace(team=self.team, user=self.user, params=params)
def _a_model(self):
portrait = _asset(self.team, self.user, name="模特.png", preview="http://tos/model.png")
return Model.objects.create(team=self.team, name="阿哲", portrait_asset=portrait)
def _submit_character(self, **over):
"""提交一条角色复刻,并立刻解开单飞闸 —— 同一个用例里要连提几条时用。"""
task = self._submit(replace_mode="character", product_id="", model_id=str(self._a_model().id), **over)
AITask.objects.filter(id=task.id).update(status=AITask.Status.SUCCEEDED)
task.refresh_from_db()
return task
def test_product_submit_waits_for_digest_and_keeps_video_out_of_review_refs(self):
"""商品复刻第一步:秒回 CREATED。审核列表只有商品图;原片快照另存,不传 Seedance。"""
task = self._submit()
@@ -656,19 +667,19 @@ class SubmitVideoReplaceTests(TestCase):
self.assertIn("@目标商品", prompt)
self.assertIn("【镜头 01】", prompt)
self.assertIn("倒进玻璃杯", prompt)
self.assertNotIn("@目标角色", prompt)
self.assertNotIn("@参考角色", prompt)
self.assertNotIn("@参考视频", prompt)
def test_character_prompt_swaps_person_not_product(self):
prompt = build_character_replace_prompt(DIGEST_SAMPLE)
self.assertIn("@目标角色", prompt)
self.assertIn("@参考角色", prompt)
self.assertIn("@参考视频", prompt)
self.assertIn("商品", prompt)
self.assertIn("声音重建规则——最高优先级", prompt)
self.assertIn("被替换人物对应的所有声音必须同步替换", prompt)
self.assertIn("不得出现原人物声线残留", prompt)
self.assertIn("嘴部动作必须与重新生成的人声一致", prompt)
self.assertNotIn("@目标商品", prompt)
# 声音不再由提示词管:实测 Seedance 编辑模式透传原音轨,写多少条声线规则都没用,
# 换声改由成片后的音色转换/变调完成。这里守住提示词只谈画面,别把那套话术加回来。
self.assertNotIn("声音重建规则", prompt)
self.assertNotIn("声线", prompt)
def test_digest_generates_once_without_source_video(self):
"""拆完只提交一次 Seedance,不把用户原片传进去。"""
@@ -709,28 +720,28 @@ class SubmitVideoReplaceTests(TestCase):
self.assertEqual(facts["用户补充信息"], "素颜霜,膏体按压泵,主打提亮")
self.assertEqual(facts["资料状态"], "部分")
def test_character_brief_drives_voice_gender(self):
"""角色信息里的性别要变成写死的声线要求
def test_character_prompt_is_single_directive(self):
"""角色复刻提示词就是一句话,不夹带角色设定
这条守的是女换男之后画面是男的声音还是女的:提示词以前引用了
一个根本不存在的系统提供的目标声音设定,模型只能猜,于是沿用原音轨
之前那版把声音规则写成五大段反复强调,实测是开头原声中途才切到目标音色;
长提示词会稀释关键指令,所以这版只留一句直给的指令subject_brief 只用来推导
性别(决定用哪条音色样音),不进提示词 别再往里塞东西
"""
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))
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)
# 一句话:不该有分段
self.assertNotIn("\n", with_voice)
self.assertIn("成年女性声线", build_character_submit_prompt("女性,25 岁,声音甜美"))
# 男女都提到(「把女的换成男的」)时不下断言,交给通用规则
self.assertNotIn("【目标声音设定】", build_character_submit_prompt("把女的换成男的"))
# 没填也要有性别跟随规则,不能再引用不存在的设定
bare = build_character_submit_prompt("")
self.assertIn("必须随目标角色改变", bare)
self.assertNotIn("系统提供的目标声音设定", bare)
# 没有音色样音时不能点名一个不存在的 @参考音频
no_voice = build_character_submit_prompt("短发", has_voice_ref=False)
self.assertIn("@参考角色", no_voice)
self.assertNotIn("@参考音频", no_voice)
def test_character_brief_lands_in_submitted_prompt(self):
portrait = _asset(self.team, self.user, name="模特.png", preview="http://tos/model.png")
@@ -740,7 +751,27 @@ class SubmitVideoReplaceTests(TestCase):
subject_brief="男性,稳重,中低音域",
)
self.assertEqual(task.request_payload["subject_brief"], "男性,稳重,中低音域")
self.assertIn("成年男性声线", task.request_payload["prompt"])
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_library_and_temp_are_exclusive(self):
portrait = _asset(self.team, self.user, name="模特.png", preview="http://tos/model.png")
@@ -753,7 +784,7 @@ class SubmitVideoReplaceTests(TestCase):
self.assertFalse(task.request_payload["digest_pending"])
labels = [item["label"] for item in task.request_payload["references"]]
self.assertIn("参考视频", labels)
self.assertIn("目标角色", labels)
self.assertIn("参考角色", labels)
def test_both_modes_use_seedance_25_and_cap_at_30s(self):
"""商品和角色都走 Seedance 2.5,参考视频按 30 秒收口。"""
@@ -860,6 +891,72 @@ class SubmitVideoReplaceTests(TestCase):
self.assertEqual(data["replace_mode"], "product")
self.assertEqual(data["subject_source"], "library")
def test_serialize_refreshes_expired_source_video_url(self):
"""历史卡「原视频」:payload 里存的是提交当时的直链(预签名 1 小时就过期),
序列化时必须按 asset_id 重新取当前直链,否则隔天点开只剩一个放不出来的播放器"""
from apps.ai.video_replace import serialize_video_replace_task
stale = "http://tos/video.mp4?X-Amz-Expires=3600&X-Amz-Signature=dead"
model_config = ModelConfig.objects.filter(capability="video").first()
task = AITask.objects.create(
team=self.team,
created_by=self.user,
task_type=AITask.Type.FREE_VIDEO,
status=AITask.Status.SUCCEEDED,
model_config=model_config,
idempotency_key="vr-serialize-refresh",
request_payload={
"feature": "video_replace",
"replace_mode": "product",
"prompt": CHARACTER_PROMPT,
"duration": 8,
"aspect_ratio": "9:16",
"references": [
{"type": "image", "url": stale, "label": "目标商品", "source": "asset", "asset_id": str(self.image.id)}
],
"digest_source_asset_id": str(self.video.id),
"digest_source_ref": {
"type": "video",
"url": stale,
"label": "参考视频",
"source": "asset",
"asset_id": str(self.video.id),
},
},
)
data = serialize_video_replace_task(task)
self.assertEqual(data["digest_source"]["url"], "http://tos/video.mp4")
self.assertEqual(data["digest_source"]["label"], "参考视频")
self.assertEqual(data["references"][0]["url"], "http://tos/product.png")
def test_serialize_keeps_snapshot_when_asset_is_gone(self):
"""资产已被删/查不到(素材库引用等)时保留原快照,别把历史卡清空。"""
from apps.ai.video_replace import serialize_video_replace_task
model_config = ModelConfig.objects.filter(capability="video").first()
task = AITask.objects.create(
team=self.team,
created_by=self.user,
task_type=AITask.Type.FREE_VIDEO,
status=AITask.Status.SUCCEEDED,
model_config=model_config,
idempotency_key="vr-serialize-missing",
request_payload={
"feature": "video_replace",
"replace_mode": "product",
"prompt": CHARACTER_PROMPT,
"digest_source_ref": {
"type": "video",
"url": "http://tos/old.mp4",
"label": "参考视频",
"source": "asset",
"asset_id": str(uuid4()),
},
},
)
data = serialize_video_replace_task(task)
self.assertEqual(data["digest_source"]["url"], "http://tos/old.mp4")
class VideoReplaceApiTests(TestCase):
def setUp(self):
+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))
@@ -1330,3 +1330,5 @@ class QuickCreateCoordinatorTests(TestCase):
statuses = list(self.project.video_segments.order_by("sort_order").values_list("status", flat=True))
self.assertEqual(statuses, [VideoSegment.Status.QUEUED, VideoSegment.Status.QUEUED])
+3 -4
View File
@@ -45,7 +45,6 @@ const REF_BYTES_MAX = { product: 200 * 1024 * 1024, character: 200 * 1024 * 1024
const SEEDANCE_MAX_OUTPUT_SECONDS = 30;
// 第 3 步补充信息上限,与后端 MAX_SUBJECT_BRIEF 保持一致
const SUBJECT_BRIEF_MAX = 500;
type ProductSource = "library" | "temporary" | "";
type ReplaceMode = "product" | "character";
@@ -108,9 +107,9 @@ const REPLACE_MODE_COPY = {
historyKind: "角色",
pickToast: "已选择角色",
briefStep: "3. 角色信息",
briefOptional: "选填 · 建议写明性别",
briefPlaceholder: "例:男性,30 岁左右,中低音域,语速平稳,声音干净有亲和力",
briefHint: "配音会按这里的设定重新生成声线,建议写明男 / 女,避免沿用原视频人物的音色。",
briefOptional: "选填",
briefPlaceholder: "例:30 岁左右男性,短发,深色衬衫,气质干练利落",
briefHint: "写明目标角色是男或女时,系统会自动使用对应的通用声音参考;未写明则保留参考视频原声。",
},
} as const;
+2
View File
@@ -625,6 +625,8 @@ export type FreeVideoTask = {
subject_name?: string;
subject_source?: "library" | "temporary" | "";
subject_brief?: string;
voice_gender?: "male" | "female" | "";
voice_strategy?: string;
product_id?: string;
model_id?: string;
review_stage?: "reviewing" | "";
+15
View File
@@ -204,6 +204,21 @@
}
/* 第 3 步补充信息。输入框本体复用共享 .textarea,这里只给提示文案留出间距。 */
.vrep-page .replace-voice-row {
display: flex;
align-items: center;
flex-wrap: wrap;
gap: 8px;
margin-top: 10px;
}
.vrep-page .replace-voice-label {
font-size: 13px;
font-weight: 500;
color: var(--accent-black);
margin-right: 2px;
}
.vrep-page .replace-brief-hint {
margin: 6px 0 0;
}