优化替换角色添加音频
This commit is contained in:
@@ -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),
|
||||
|
||||
@@ -132,11 +132,11 @@ PERSONA_BRIEFS: dict[str, str] = {
|
||||
}
|
||||
_PERSONA_KEY_BY_LABEL = {label: key for key, label in PERSONA_LABELS.items()}
|
||||
|
||||
# 电商口播需要同时承载观点、证据和转折;15 秒以 4.3–5.2 字/秒为可用区间。
|
||||
# 这比日常闲聊快,但仍给句间停顿和镜头切换留出空间。
|
||||
NARRATION_CHARS_PER_SECOND = 5.2
|
||||
NARRATION_CHARS_PER_SECOND_MIN = 4.3
|
||||
NARRATION_CHARS_HARD_CAP = 78
|
||||
# 电商口播需要同时承载观点、证据和转折;15 秒固定以 5.0–5.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)))
|
||||
|
||||
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -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])
|
||||
|
||||
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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" | "";
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user