diff --git a/core/backend/apps/ai/free_video.py b/core/backend/apps/ai/free_video.py index dc1ae72..db7f505 100644 --- a/core/backend/apps/ai/free_video.py +++ b/core/backend/apps/ai/free_video.py @@ -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), diff --git a/core/backend/apps/ai/script_agent.py b/core/backend/apps/ai/script_agent.py index e3d4d4a..fe71e5a 100644 --- a/core/backend/apps/ai/script_agent.py +++ b/core/backend/apps/ai/script_agent.py @@ -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))) diff --git a/core/backend/apps/ai/services.py b/core/backend/apps/ai/services.py index d22b656..cd3a144 100644 --- a/core/backend/apps/ai/services.py +++ b/core/backend/apps/ai/services.py @@ -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 也能引用);未进库才回落原始直链。 diff --git a/core/backend/apps/ai/test_script_duration_combo.py b/core/backend/apps/ai/test_script_duration_combo.py index 35b961f..8b5e8f0 100644 --- a/core/backend/apps/ai/test_script_duration_combo.py +++ b/core/backend/apps/ai/test_script_duration_combo.py @@ -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( diff --git a/core/backend/apps/ai/test_video_replace.py b/core/backend/apps/ai/test_video_replace.py index 7ace441..22d0716 100644 --- a/core/backend/apps/ai/test_video_replace.py +++ b/core/backend/apps/ai/test_video_replace.py @@ -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): diff --git a/core/backend/apps/ai/video_replace.py b/core/backend/apps/ai/video_replace.py index 02d9165..14d3e83 100644 --- a/core/backend/apps/ai/video_replace.py +++ b/core/backend/apps/ai/video_replace.py @@ -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)) diff --git a/core/backend/apps/projects/test_quick_create.py b/core/backend/apps/projects/test_quick_create.py index 304ea82..9ff5267 100644 --- a/core/backend/apps/projects/test_quick_create.py +++ b/core/backend/apps/projects/test_quick_create.py @@ -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]) + + diff --git a/core/frontend/src/routes/video-replace.tsx b/core/frontend/src/routes/video-replace.tsx index f04f536..4ac09d7 100644 --- a/core/frontend/src/routes/video-replace.tsx +++ b/core/frontend/src/routes/video-replace.tsx @@ -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; diff --git a/core/frontend/src/types.ts b/core/frontend/src/types.ts index 5eb2a9f..cd611af 100644 --- a/core/frontend/src/types.ts +++ b/core/frontend/src/types.ts @@ -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" | ""; diff --git a/core/frontend/src/video-replace-page.css b/core/frontend/src/video-replace-page.css index f43d36d..9437522 100644 --- a/core/frontend/src/video-replace-page.css +++ b/core/frontend/src/video-replace-page.css @@ -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; }