From 284748eeff61fd4e97c8e9c655c4c47e7865f048 Mon Sep 17 00:00:00 2001 From: "Azmat@qq.com" Date: Mon, 31 Aug 2026 10:13:56 +0800 Subject: [PATCH] =?UTF-8?q?=E6=B5=8B=E8=AF=95=E8=A7=86=E9=A2=91=E5=A4=8D?= =?UTF-8?q?=E5=88=BB?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- core/backend/airshelf/settings/test.py | 9 + core/backend/apps/ai/catalog.py | 2 +- core/backend/apps/ai/free_video.py | 9 +- core/backend/apps/ai/test_video_replace.py | 236 +++++++++------------ core/backend/apps/ai/video_replace.py | 156 ++++++-------- core/backend/apps/ai/views.py | 5 +- core/frontend/src/routes/video-replace.tsx | 21 +- 7 files changed, 193 insertions(+), 245 deletions(-) diff --git a/core/backend/airshelf/settings/test.py b/core/backend/airshelf/settings/test.py index 12e8dd2..247a4d8 100644 --- a/core/backend/airshelf/settings/test.py +++ b/core/backend/airshelf/settings/test.py @@ -11,3 +11,12 @@ DATABASES = { PASSWORD_HASHERS = ["django.contrib.auth.hashers.MD5PasswordHasher"] CELERY_TASK_ALWAYS_EAGER = True CELERY_TASK_EAGER_PROPAGATES = True + +# 测试必须完全离线:base.py 的 Redis cache 会让 billing 等普通业务逻辑在 +# 单测中意外连到实际环境。测试用进程内缓存即可,既不依赖网络,也不会碰线上数据。 +CACHES = { + "default": { + "BACKEND": "django.core.cache.backends.locmem.LocMemCache", + "LOCATION": "airshelf-test-cache", + } +} diff --git a/core/backend/apps/ai/catalog.py b/core/backend/apps/ai/catalog.py index df00b21..67d370a 100644 --- a/core/backend/apps/ai/catalog.py +++ b/core/backend/apps/ai/catalog.py @@ -66,7 +66,7 @@ VOLCANO_MODELS = [ }, }, { - # 视频复刻(商品 / 角色)固定用这一档:只有它支持 30 秒单次出片。 + # 视频复刻固定用这一档(单次最长 30 秒)。原片只提炼分镜,不传给火山。 # ⚠️ pricing 暂用 Seedance-2.0 标准档的数字占位 —— 火山 2.5 的官方价目待核对。 # 偏低只会让平台少收,不会多扣用户;核准后改这里(或直接在运营后台改 metadata.pricing)。 "display_name": "Seedance-2.5", diff --git a/core/backend/apps/ai/free_video.py b/core/backend/apps/ai/free_video.py index 61e0404..1dc4f87 100644 --- a/core/backend/apps/ai/free_video.py +++ b/core/backend/apps/ai/free_video.py @@ -44,7 +44,7 @@ FREE_VIDEO_MODELS = { "doubao-seedance-2-0-mini-260615", } HIGH_RES_MODEL = "doubao-seedance-2-0-260128" # 1080p/4k 仅标准档(火山限制) -# 视频复刻固定用这一档:只有它支持 30 秒单次出片 +# 视频复刻固定 Seedance 2.5(单次最长 30 秒)。原片只用来提炼分镜,不传给火山。 REPLACE_MODEL = "doubao-seedance-2-5-260628" # 出片时长的兜底上限。真实上限按 ModelConfig.metadata.durations 取(见 model_duration_range), # 元数据缺失时回落这里 —— 别写死 15,否则新模型上来就被老常量卡住。 @@ -350,7 +350,7 @@ def build_content_items( if audio_n > 0 and image_n + video_n == 0: raise ValueError("音频不能单独作为参考素材,请同时提供参考图片或视频") if video_duration_total > max_ref_seconds: - # 上限跟着模型走:自由创作是 Seedance 2.0(15 秒),视频复刻走 2.5(30 秒)。 + # 上限跟着模型走:2.0 是 15 秒,2.5 是 30 秒。 raise ValueError(f"参考视频总时长不能超过 {int(max_ref_seconds)} 秒,请缩短后重试") # @label 替换:按 label 长度降序,防子串吞噬 @@ -619,7 +619,10 @@ def start_pending_free_video(task: AITask) -> AITask: seed = int(payload.get("seed") if payload.get("seed") is not None else -1) except (TypeError, ValueError): seed = -1 - references = payload.get("references") or [] + references = list(payload.get("references") or []) + if feature == "video_replace": + # 复刻:用户原片只用来提炼,绝不能进 Seedance,否则真人素材直接被火山拒。 + references = [item for item in references if (item or {}).get("type") != "video"] try: built = build_content_items( team=task.team, prompt=prompt, mode=mode, references=references, diff --git a/core/backend/apps/ai/test_video_replace.py b/core/backend/apps/ai/test_video_replace.py index 5a9084f..aebd488 100644 --- a/core/backend/apps/ai/test_video_replace.py +++ b/core/backend/apps/ai/test_video_replace.py @@ -11,7 +11,7 @@ from django.test import TestCase from rest_framework.test import APIClient from apps.accounts.models import Team, TeamMember, User -from apps.ai.free_video import submit_free_video +from apps.ai.free_video import REPLACE_MODEL, submit_free_video from apps.ai.models import AITask, ModelConfig from apps.ai.test_free_video import STANDARD, _ark_create_response from apps.ai.video_replace import ( @@ -20,8 +20,8 @@ from apps.ai.video_replace import ( REVIEW_FAILED, REVIEW_UNAVAILABLE, advance_video_replace, - build_shot_plan, - compact_digest_for_video, + build_character_replace_prompt, + build_product_replace_prompt, run_replace_digest, submit_video_replace, ) @@ -175,7 +175,7 @@ class SubmitVideoReplaceTests(TestCase): return submit_video_replace(team=self.team, user=self.user, params=params) def test_product_submit_waits_for_digest_and_keeps_video_out_of_review_refs(self): - """商品复刻第一步:秒回 CREATED。审核列表只有商品图;原片快照另存,出片时再带上。""" + """商品复刻第一步:秒回 CREATED。审核列表只有商品图;原片快照另存,不传 Seedance。""" task = self._submit() self.assertEqual(task.status, AITask.Status.CREATED) payload = task.request_payload @@ -193,7 +193,7 @@ class SubmitVideoReplaceTests(TestCase): self.assertEqual(CreditReservation.objects.filter(task=task).count(), 0) def test_product_digest_becomes_prompt_and_video_goes_to_ark(self): - """第二步:worker 拆完 → 按镜拆成生成计划 → 原片 + 商品图一起提交第一镜。""" + """第二步:worker 拆完 → 整份分镜稿一次交给 Seedance 2.5。原片不传火山。""" task = self._submit() with patch( "apps.ai.video_digest.digest_asset_video", @@ -205,28 +205,25 @@ class SubmitVideoReplaceTests(TestCase): payload = task.request_payload self.assertFalse(payload["digest_pending"]) self.assertEqual(payload["digest_shots"], 2) - plan = payload["shot_plan"] - self.assertEqual(len(plan), 2) - self.assertEqual(plan[0]["seconds"], 4) - self.assertEqual(plan[1]["seconds"], 4) + self.assertFalse(payload.get("shot_plan")) prompt = payload["prompt"] - self.assertIn("@参考视频", prompt) # 2.0 锁切镜的锚点 - self.assertIn("第 1/", prompt) # 只喂当前这一镜 - self.assertIn("倒进玻璃杯", prompt) # 提炼原文画面保留 - self.assertIn("台词/旁白", prompt) # 不改写成「口播」 + self.assertIn("【镜头 01】", prompt) + self.assertIn("【镜头 02】", prompt) + self.assertIn("倒进玻璃杯", prompt) + self.assertIn("转动瓶身", prompt) + self.assertIn("台词/旁白", prompt) self.assertIn("@目标商品", prompt) self.assertIn("净颜精华", prompt) self.assertNotIn("【拆解存疑】", prompt) - self.assertNotIn("字幕:", prompt) - self.assertNotIn("转动瓶身", prompt) # 第二镜的动作不能漏进第一镜 + self.assertNotIn("@参考视频", prompt) self.assertEqual(self.provider.create_video_task.call_count, 1) - self.assertEqual(self.provider.create_video_task.call_args.kwargs.get("duration"), 4) + self.assertEqual(self.provider.create_video_task.call_args.kwargs.get("duration"), 8) content = self.provider.create_video_task.call_args.kwargs.get("content_items") or [] roles = [item.get("role") for item in content] self.assertIn("reference_image", roles) - self.assertIn("reference_video", roles) - self.assertIn("video_url", [item.get("type") for item in content]) + self.assertNotIn("reference_video", roles) + self.assertNotIn("video_url", [item.get("type") for item in content]) def _retire(self, task): """把任务收成终态,好让单飞闸放行下一次提交。""" @@ -262,7 +259,7 @@ class SubmitVideoReplaceTests(TestCase): run_replace_digest(task) task.refresh_from_db() self.assertEqual(task.status, AITask.Status.SUBMITTED) - self.assertIn("第 1/", task.request_payload["prompt"]) + self.assertIn("【镜头 01】", task.request_payload["prompt"]) self.assertIn("@目标商品", task.request_payload["prompt"]) def test_replace_digest_prompt_matches_standalone(self): @@ -512,100 +509,71 @@ class SubmitVideoReplaceTests(TestCase): self.assertEqual(len(refs), MAX_IMAGES) self.assertEqual(refs[-1]["label"], "目标商品三视图") - def test_digest_becomes_a_shot_table_for_the_video_model(self): - """分镜稿是给人校对的导演稿。喂给视频模型前要转成只讲「镜头怎么拍、人说什么」 - 的镜头表 —— 元信息和音轨描述留着只会稀释真正的画面指令。""" - table = compact_digest_for_video(DIGEST_SAMPLE) - # 镜头结构和画面指令留下 - self.assertIn("镜头 01 | 00:00-00:04", table) - self.assertIn("镜头 02 | 00:04-00:08", table) - self.assertIn("画面:女生站在窗边", table) - self.assertIn("口播:每天早上我都要来一支旧牌精华。", table) - self.assertIn("镜头数:2 镜", table) - # 整片设定只留对画面有用的 - self.assertIn("整体风格:写实、暖色调、清晨感", table) - self.assertNotIn("片名", table) - self.assertNotIn("视频类型", table) - self.assertNotIn("结构:", table) - # 非画面指令全部剔除 - for noise in ("字幕:", "音效:", "背景音乐:", "备注:", "【拆解存疑】"): - self.assertNotIn(noise, table) - # 空值行不占篇幅("表情:不可见" 这种) - self.assertNotIn("不可见", table) + def test_digest_keeps_remix_page_shot_skeleton(self): + """喂给 Seedance 的稿要和「提炼提示词」页一样:【镜头 01】骨架原文照传。""" + from apps.ai.video_replace import _digest_for_seedance + + out = _digest_for_seedance(DIGEST_SAMPLE) + self.assertIn("【镜头 01】", out) + self.assertIn("【镜头 02】", out) + self.assertIn("台词/旁白:每天早上我都要来一支旧牌精华。", out) + self.assertIn("字幕:每天早上来一支", out) + self.assertNotIn("【拆解存疑】", out) def test_unparseable_digest_falls_back_to_raw_text(self): """模型没按 skill 格式吐时,宁可原文照传也不能把镜头信息弄丢。""" + from apps.ai.video_replace import _digest_for_seedance + messy = "这条片子讲了一个女生的早晨。\n她拿起瓶子。\n【拆解存疑】\n- 看不清" - out = compact_digest_for_video(messy) + out = _digest_for_seedance(messy) self.assertIn("这条片子讲了一个女生的早晨。", out) self.assertNotIn("【拆解存疑】", out) - def test_shot_plan_keeps_one_seedance_job_per_shot(self): - plan = build_shot_plan(DIGEST_SAMPLE, "净颜精华", output_seconds=8) - self.assertEqual([item["seconds"] for item in plan], [4, 4]) - self.assertIn("第 1/", plan[0]["prompt"]) - self.assertIn("@参考视频", plan[0]["prompt"]) - self.assertIn("倒进玻璃杯", plan[0]["prompt"]) - self.assertIn("台词/旁白", plan[0]["prompt"]) - self.assertNotIn("转动瓶身", plan[0]["prompt"]) - self.assertIn("第 2/", plan[1]["prompt"]) - self.assertIn("@目标商品", plan[0]["prompt"]) - self.assertNotIn("@目标角色", plan[0]["prompt"]) + def test_product_prompt_swaps_product_not_person(self): + prompt = build_product_replace_prompt(DIGEST_SAMPLE, "净颜精华") + self.assertIn("@目标商品", prompt) + self.assertIn("【镜头 01】", prompt) + self.assertIn("倒进玻璃杯", prompt) + self.assertNotIn("@目标角色", prompt) + self.assertNotIn("@参考视频", prompt) - def test_character_shot_plan_swaps_person_not_product(self): - plan = build_shot_plan(DIGEST_SAMPLE, "薇薇", replace_mode="character", output_seconds=8) - self.assertIn("@目标角色", plan[0]["prompt"]) - self.assertIn("@参考视频", plan[0]["prompt"]) - self.assertIn("商品", plan[0]["prompt"]) - self.assertNotIn("@目标商品", plan[0]["prompt"]) - self.assertNotIn("净颜精华", plan[0]["prompt"]) + def test_character_prompt_swaps_person_not_product(self): + prompt = build_character_replace_prompt(DIGEST_SAMPLE) + self.assertIn("@目标角色", prompt) + self.assertIn("【镜头 01】", prompt) + self.assertIn("商品", prompt) + self.assertNotIn("@目标商品", prompt) + self.assertNotIn("@参考视频", prompt) - def test_adjacent_sub_four_second_shots_merge(self): - digest = """【镜头 01】 -时长:2秒 -画面:第一下。 -【镜头 02】 -时长:2秒 -画面:第二下。 -""" - plan = build_shot_plan(digest, "精华", output_seconds=8) - self.assertEqual(len(plan), 1) - self.assertEqual(plan[0]["seconds"], 4) - self.assertIn("切点", plan[0]["prompt"]) - self.assertIn("第一下", plan[0]["prompt"]) - self.assertIn("第二下", plan[0]["prompt"]) - - def test_shots_are_generated_one_by_one_then_concatenated(self): - """2.5 不能一次塞完整镜头表。第一镜出完再提第二镜,全部完成才拼接。""" - self.provider.create_video_task.side_effect = [ - _ark_create_response("ark-1"), - _ark_create_response("ark-2"), - ] - self.provider.poll_video_task.side_effect = [ - {"status": "succeeded", "usage": {"total_tokens": 1000}}, - {"status": "succeeded", "usage": {"total_tokens": 1200}}, - ] - self.provider.extract_first_media_url.side_effect = ["http://ark/s1.mp4", "http://ark/s2.mp4"] + def test_digest_generates_once_without_source_video(self): + """拆完只提交一次 Seedance,不把用户原片传进去。""" task = self._run_digest(self._submit()) - self.assertEqual(task.provider_task_id, "ark-1") self.assertEqual(self.provider.create_video_task.call_count, 1) + self.assertEqual(self.provider.create_video_task.call_args.kwargs.get("duration"), 8) + content = self.provider.create_video_task.call_args.kwargs.get("content_items") or [] + self.assertNotIn("video_url", [item.get("type") for item in content]) + self.assertIn("【镜头 01】", task.request_payload["prompt"]) + self.assertIn("【镜头 02】", task.request_payload["prompt"]) + self.assertFalse(task.request_payload.get("shot_plan")) - with patch("apps.ai.video_replace._concat_shot_media", return_value=b"concat-bytes") as concat, patch( - "apps.ai.free_video._store_free_video_media" - ) as store: - task = advance_video_replace(task) - self.assertEqual(task.provider_task_id, "ark-2") - self.assertEqual(self.provider.create_video_task.call_count, 2) - self.assertEqual(task.request_payload["shot_plan"][0]["status"], "succeeded") - self.assertFalse(store.called) - - task = advance_video_replace(task) - self.assertEqual(task.status, AITask.Status.SUCCEEDED) - concat.assert_called_once() - self.assertEqual(concat.call_args[0][0], ["http://ark/s1.mp4", "http://ark/s2.mp4"]) - store.assert_called_once() - self.assertEqual(store.call_args.kwargs.get("video_bytes"), b"concat-bytes") - self.assertEqual(task.request_payload.get("actual_tokens"), 2200) + def test_leftover_source_video_is_stripped_before_seedance(self): + """旧任务 payload 里还躺着原片 / shot_plan 时,出片入口也必须丢掉,不能再喂火山。""" + task = self._submit() + payload = dict(task.request_payload) + payload["references"] = [ + payload["digest_source_ref"], + *(payload.get("references") or []), + ] + payload["shot_plan"] = [{"index": 1, "seconds": 7, "prompt": "使用@参考视频", "status": "queued"}] + payload["shot_total"] = 5 + task.request_payload = payload + task.save(update_fields=["request_payload", "updated_at"]) + task = self._run_digest(task) + self.assertEqual(self.provider.create_video_task.call_count, 1) + content = self.provider.create_video_task.call_args.kwargs.get("content_items") or [] + self.assertNotIn("video_url", [item.get("type") for item in content]) + self.assertFalse(task.request_payload.get("shot_plan")) + self.assertNotIn("@参考视频", task.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") @@ -620,61 +588,67 @@ class SubmitVideoReplaceTests(TestCase): self.assertNotIn("参考视频", labels) self.assertIn("目标角色", labels) - def test_both_modes_accept_up_to_30s(self): - """商品和角色都走 Seedance 2.5(单次能出 30 秒),参考视频统一按 30 秒收口。""" + def test_both_modes_use_seedance_25_and_cap_at_30s(self): + """商品和角色都走 Seedance 2.5,参考视频按 30 秒收口。""" portrait = _asset(self.team, self.user, name="模特.png", preview="http://tos/model.png") model = Model.objects.create(team=self.team, name="薇薇", portrait_asset=portrait) - mid = _asset(self.team, self.user, kind=Asset.Type.VIDEO, name="半分钟.mp4", duration_ms=30000) - too_long = _asset(self.team, self.user, kind=Asset.Type.VIDEO, name="超长.mp4", duration_ms=45000) + mid = _asset(self.team, self.user, kind=Asset.Type.VIDEO, name="三十秒.mp4", duration_ms=30000) + too_long = _asset(self.team, self.user, kind=Asset.Type.VIDEO, name="超长.mp4", duration_ms=32000) # 单飞闸只允许一条在跑,所以每次提交后收掉再试下一条 product_task = self._retire(self._submit(video_asset_id=str(mid.id))) self.assertEqual(product_task.status, AITask.Status.CANCELLED) - self.assertEqual(product_task.request_payload["model"], "doubao-seedance-2-5-260628") + self.assertEqual(product_task.request_payload["model"], REPLACE_MODEL) character_task = self._retire(self._submit( replace_mode="character", product_id="", model_id=str(model.id), video_asset_id=str(mid.id), )) - self.assertEqual(character_task.request_payload["model"], "doubao-seedance-2-5-260628") + self.assertEqual(character_task.request_payload["model"], REPLACE_MODEL) for mode, extra in (("product", {}), ("character", {"product_id": "", "model_id": str(model.id)})): with self.assertRaisesMessage(ValueError, "不能超过 30 秒"): self._submit(replace_mode=mode, video_asset_id=str(too_long.id), **extra) - def test_long_source_drops_trailing_shots(self): - """参考片比成片长很多时整镜省略尾镜,不要压缩画面。""" - long_video = _asset(self.team, self.user, kind=Asset.Type.VIDEO, name="半分钟.mp4", duration_ms=28000) + def test_long_source_adds_condense_note(self): + """参考片比成片长很多时只加一句取舍,整份分镜稿仍一次出。""" + long_video = _asset(self.team, self.user, kind=Asset.Type.VIDEO, name="十五秒.mp4", duration_ms=15000) task = self._run_digest(self._submit(video_asset_id=str(long_video.id), duration=10), digest=DIGEST_FOUR) - plan = task.request_payload["shot_plan"] - self.assertEqual(len(plan), 2) - self.assertIn("第一镜开场", plan[0]["prompt"]) - self.assertIn("第二镜展示", plan[1]["prompt"]) - self.assertNotIn("第四镜收束", plan[0]["prompt"] + plan[1]["prompt"]) + self.assertFalse(task.request_payload.get("shot_plan")) + prompt = task.request_payload["prompt"] + self.assertIn("【镜头 01】", prompt) + self.assertIn("第四镜收束", prompt) + self.assertIn("省略次要镜头", prompt) + self.assertEqual(self.provider.create_video_task.call_count, 1) + self.assertEqual(self.provider.create_video_task.call_args.kwargs.get("duration"), 10) def test_short_source_keeps_all_shots(self): - """时长一致时保留全部镜头。""" + """时长一致时保留全部镜头,不加取舍句。""" task = self._run_digest(self._submit(duration=None)) # setUp 的参考视频 8 秒 → 成片也 8 秒 - self.assertEqual(len(task.request_payload["shot_plan"]), 2) - self.assertNotIn("时长不一致时的取舍", task.request_payload["prompt"]) - self.assertNotIn("省略次要镜头", task.request_payload["prompt"]) + self.assertFalse(task.request_payload.get("shot_plan")) + prompt = task.request_payload["prompt"] + self.assertIn("【镜头 01】", prompt) + self.assertIn("【镜头 02】", prompt) + self.assertNotIn("省略次要镜头", prompt) - def test_prompt_copies_source_video_instead_of_rewriting_a_script(self): - """切镜靠 @参考视频,提示词只钉「换商品」+ 这一镜原文,不要再扩写成导演长文。""" + def test_prompt_keeps_digest_page_format(self): + """分镜稿格式和提炼页一样,只加换商品。不传原片、不改写成导演长文。""" prompt = self._run_digest(self._submit()).request_payload["prompt"] - self.assertIn("@参考视频", prompt) - self.assertIn("人物", prompt) + self.assertIn("【镜头 01】", prompt) + self.assertIn("时间:00:00-00:04", prompt) self.assertIn("台词/旁白", prompt) + self.assertNotIn("@参考视频", prompt) self.assertNotIn("必须原样保持", prompt) self.assertNotIn("不要从商品图凭空套脸", prompt) - def test_accepts_15s_video_with_probe_slack(self): - from apps.ai.media_probe import REF_DURATION_MAX - from apps.ai.video_replace import _asset_duration_seconds + def test_accepts_30s_video_with_probe_slack(self): + from apps.ai.video_replace import REPLACE_REF_DURATION_MAX, _asset_duration_seconds - # Finder 显示 15 秒的成片常被探测成 15.04s,不能被硬阈值 15 误拒 - edge = _asset(self.team, self.user, kind=Asset.Type.VIDEO, name="刚好15.mp4", duration_ms=15040) - self.assertLessEqual(_asset_duration_seconds(edge), REF_DURATION_MAX) + # Finder 显示 30 秒的成片常被探测成 30.04s,不能被硬阈值 30 误拒 + edge = _asset(self.team, self.user, kind=Asset.Type.VIDEO, name="刚好30.mp4", duration_ms=30040) + self.assertLessEqual(_asset_duration_seconds(edge), REPLACE_REF_DURATION_MAX) + task = self._retire(self._submit(video_asset_id=str(edge.id))) + self.assertEqual(task.request_payload["model"], REPLACE_MODEL) def test_rejects_foreign_product(self): other = User.objects.create_user(username="vr-other", password="p") @@ -807,7 +781,7 @@ class VideoReplaceApiTests(TestCase): class VideoReplaceReviewGateTests(TestCase): - """审核闸只盯人物图/商品图。原片出片时直传火山锁切镜,不进人物素材库送审。""" + """审核闸只盯人物图/商品图。用户原片只用来提炼,不传 Seedance。""" def setUp(self): self.user = User.objects.create_user(username="vrrev", password="p") @@ -891,7 +865,7 @@ class VideoReplaceReviewGateTests(TestCase): self.assertTrue(self.provider.create_video_task.called) content = self.provider.create_video_task.call_args.kwargs.get("content_items") or [] types = [item.get("type") for item in content] - self.assertIn("video_url", types) + self.assertNotIn("video_url", types) image_urls = [ (item.get("image_url") or {}).get("url") for item in content if item.get("type") == "image_url" diff --git a/core/backend/apps/ai/video_replace.py b/core/backend/apps/ai/video_replace.py index c63faf7..d5714ed 100644 --- a/core/backend/apps/ai/video_replace.py +++ b/core/backend/apps/ai/video_replace.py @@ -1,10 +1,11 @@ -"""视频复刻:参考视频 + 商品图/人物图 → Seedance 2.5 换商品或换角色。 +"""视频复刻:参考视频提炼分镜稿 → Seedance 2.5 按稿出片。 -2.0 效果好是因为把 @参考视频 直接喂给火山锁切镜。2.5 单次能出 30 秒,模型换成 -2.5 即可;不要改写成一大段分镜散文再文生视频,否则切镜对不上、描写过头。 +流程很短: +1. 用户上传的参考视频只走「提炼提示词」同一套接口,拆成中文分镜稿; +2. 把这份稿(格式与提炼页一模一样)加上换商品/换角色,交给 Seedance 2.5; +3. 只带商品图/角色图。用户原片不传火山,避免素材审核报错。 -流程:「提炼提示词」同一套接口拆出每镜时间轴 → 一镜一次 Seedance(仍带上原片) -→ 按原片顺序拼接。商品图/人物图过审后用 asset://;审核失败不扣费。 +商品图/人物图过审后用 asset://;审核失败不扣费。 """ from __future__ import annotations @@ -78,19 +79,15 @@ def get_inflight_video_replace(team): .first() ) -# 2.0 就是这四句话 + @参考视频。不要再扩写成「镜头表 / 必须原样保持」长文, -# 2.5 会把多出来的描写当成新画面去演。 PRODUCT_PROMPT = ( - "使用@参考视频作为镜头、节奏与口播氛围基准," - "将画面中需要替换的原商品完整替换为@目标商品的外观。" - "保留参考视频的人物、场景、镜头运动、剪辑节奏与口播氛围," - "商品外观、材质、包装必须与参考图一致,不要改变原片构图和人物表演。" + "按下面这份分镜稿生成视频。镜头数量、每镜时长、景别、机位、运镜、人物、场景、口播全部按稿执行," + "不要增删镜头、不要改顺序。" + "把稿里的原商品换成@目标商品,外观、材质、包装以参考图为准。" ) CHARACTER_PROMPT = ( - "使用@参考视频作为镜头、节奏与口播氛围基准," - "将画面中需要替换的原人物完整替换为@目标角色。" - "保留参考视频的商品、场景、镜头运动、剪辑节奏与口播氛围," - "角色五官、发型、体态必须与参考图一致,不要改变原片构图和商品展示。" + "按下面这份分镜稿生成视频。镜头数量、每镜时长、景别、机位、运镜、商品、场景、口播全部按稿执行," + "不要增删镜头、不要改顺序。" + "把稿里的原人物换成@目标角色,五官、发型、体态以参考图为准。" ) # 拆解期间的占位提示词。真正的提示词在 worker 拆完后回写。 @@ -175,10 +172,9 @@ def _render_shot_table(header: dict, shots: list[dict]) -> str: # —— 逐镜生成 —— -# 2.5 一次塞整条 30 秒会自己切镜。拆成一镜一次,每段只还原原片对应时间, -# 并且把 @参考视频 一起带上(2.0 锁切镜的那一招),再按顺序拼起来。 +# 一镜一次 Seedance,每段只还原原片对应时间,并把 @参考视频 带上锁切镜。 MIN_SHOT_SECONDS = 4 # 火山单次出片下限 -MAX_CLIP_SECONDS = 30 # Seedance 2.5 单次上限;单镜再长也一次出,避免凭空切一刀 +MAX_CLIP_SECONDS = 15 # Seedance 2.0 单次上限;单镜再长也一次出,避免凭空切一刀 MAX_SHOTS = 12 # 再多就是积分黑洞,也超出用户预期 _CLOCK_RE = re.compile(r"(\d{1,2}):(\d{2})") @@ -339,17 +335,24 @@ def build_shot_prompt( return enforce_no_embedded_captions("\n".join(part for part in parts if part)) +def _digest_for_seedance(digest_text: str) -> str: + """提炼页同款分镜稿。只砍给用户校对的【拆解存疑】,其余原文照传。""" + body = (digest_text or "").strip() + if not body: + raise ValueError("参考视频拆解结果为空,请重试") + head, sep, _tail = body.partition(DIGEST_TAIL_SECTION) + return (head if sep else body).strip() + + def build_character_replace_prompt( digest_text: str, *, source_seconds: float = 0, output_seconds: int = 0, ) -> str: - """解析不出逐镜计划时的整片回落:2.0 短指令,切镜靠 @参考视频。""" + """提炼页分镜稿 + 换角色。原片不传 Seedance。""" from .services import enforce_no_embedded_captions - if not (digest_text or "").strip(): - raise ValueError("参考视频拆解结果为空,请重试") source = int(round(float(source_seconds or 0))) output = int(output_seconds or 0) condense = ( @@ -357,7 +360,9 @@ def build_character_replace_prompt( if source and output and source - output >= 3 else "" ) - return enforce_no_embedded_captions("\n".join(part for part in (CHARACTER_PROMPT, condense) if part)) + return enforce_no_embedded_captions( + "\n".join(part for part in (CHARACTER_PROMPT, _digest_for_seedance(digest_text), condense) if part) + ) def compact_digest_for_video(digest_text: str) -> str: @@ -382,11 +387,9 @@ def build_product_replace_prompt( source_seconds: float = 0, output_seconds: int = 0, ) -> str: - """解析不出逐镜计划时的整片回落:2.0 短指令,切镜靠 @参考视频。""" + """提炼页分镜稿 + 换商品。原片不传 Seedance。""" from .services import enforce_no_embedded_captions - if not (digest_text or "").strip(): - raise ValueError("参考视频拆解结果为空,请重试") subject = (subject_name or "").strip() source = int(round(float(source_seconds or 0))) output = int(output_seconds or 0) @@ -400,7 +403,9 @@ def build_product_replace_prompt( extra.append(PRODUCT_TRIVIEW_NOTE) if subject: extra.append(f"口播里的旧商品名改说「{subject}」。") - return enforce_no_embedded_captions("\n".join(part for part in (PRODUCT_PROMPT, *extra, condense) if part)) + return enforce_no_embedded_captions( + "\n".join(part for part in (PRODUCT_PROMPT, *extra, _digest_for_seedance(digest_text), condense) if part) + ) def is_video_replace_task(task) -> bool: @@ -508,7 +513,7 @@ def submit_video_replace(*, team, user, params: dict): } base_params = { "mode": "universal", - # 商品和角色都固定走 Seedance 2.5:只有它支持 30 秒单次出片,不接受前端指定别的档 + # 固定 Seedance 2.5,不接受前端指定别的档 "model": REPLACE_MODEL, "aspect_ratio": str(params.get("aspect_ratio") or "9:16"), "resolution": str(params.get("resolution") or "720p"), @@ -518,8 +523,7 @@ def submit_video_replace(*, team, user, params: dict): "feature": FEATURE, } - # 分镜仍用「提炼提示词」同一套拆。参考视频先不进审核列表(原片不是要登记的人物素材), - # 出片时再带上 @参考视频 锁切镜 —— 这是 2.0 效果好的原因,2.5 同样需要。 + # 分镜用「提炼提示词」同一套拆。原片只当拆解源,不进审核列表、也不传火山。 from .video_digest import resolve_digest_model_config if resolve_digest_model_config() is None: @@ -533,7 +537,7 @@ def submit_video_replace(*, team, user, params: dict): "digest_source_asset_id": str(video.id), "digest_source_name": video.name or "参考视频", "digest_source_duration": round(video_seconds, 2), - # 审核只盯商品图/角色图。原片快照留给出片时锁切镜,也给历史卡「原视频」用。 + # 原片快照只给历史卡「原视频」和重新生成用,不进 Seedance references。 "digest_source_ref": _owned_ref(video, kind="video", role="reference_video", label="参考视频"), "review_pending": review_state != "ready", }) @@ -551,15 +555,10 @@ def submit_video_replace(*, team, user, params: dict): def advance_video_replace(task): - """推进复刻:审核中 → 提交;逐镜生成中 → 下一段 / 拼接。""" + """推进复刻:审核中 → 整份分镜稿一次出片。旧逐镜任务也按单次收尾,不再拼接。""" if not is_video_replace_task(task): return task if task.status != AITask.Status.CREATED: - payload = task.request_payload or {} - if payload.get("shot_plan") and task.status in ( - AITask.Status.RESERVED, AITask.Status.SUBMITTED, AITask.Status.POLLING, - ): - return advance_replace_shots(task) from .free_video import finalize_free_video return finalize_free_video(task=task) @@ -569,7 +568,7 @@ def advance_video_replace(task): # worker 还在拆参考视频,提示词都没生成,别当成「审核中」反复送审。 # worker 挂了也不会永远卡着:CREATED 超 16 分钟由 _reap_stale_free_video_tasks 回收退费。 return task - references = list(payload.get("references") or []) + references = _image_refs(payload.get("references") or []) try: state = _ensure_replace_refs_reviewed(task.team, references) except ValueError as exc: @@ -598,21 +597,18 @@ def advance_video_replace(task): def start_replace_generation(task): - """审核过了:有逐镜计划就一镜一次,否则回落整片一次生成(仍带 @参考视频)。""" - payload = task.request_payload or {} - if payload.get("shot_plan"): - return start_pending_replace_shots(task) - refs = _seedance_references(payload) - if refs: - next_payload = dict(payload) - next_payload["references"] = refs - task.request_payload = next_payload - task.save(update_fields=["request_payload", "updated_at"]) + """审核过了:整份分镜稿一次交给 Seedance 2.5。只带商品图/角色图,不传用户原片。""" + payload = dict(task.request_payload or {}) + payload["references"] = _image_refs(payload.get("references") or []) + payload.pop("shot_plan", None) + payload.pop("shot_total", None) + task.request_payload = payload + task.save(update_fields=["request_payload", "updated_at"]) return start_pending_free_video(task) def run_replace_digest(task) -> None: - """Worker:参考视频 → 分镜稿 → 逐镜计划 → 走审核提交火山。 + """Worker:参考视频 → 提炼页同款分镜稿 → 一次交给 Seedance。不传原片。 商品和角色共用。这一步失败 = 整条复刻失败。此时还没预留视频积分,不扣费。 """ @@ -642,13 +638,7 @@ def run_replace_digest(task) -> None: try: digest, meta = digest_asset_video(asset=asset, task=task) - plan = build_shot_plan( - digest, subject, has_triview=has_triview, - replace_mode=replace_mode, output_seconds=output_seconds, - ) - if plan: - prompt = plan[0]["prompt"] - elif replace_mode == "character": + if replace_mode == "character": prompt = build_character_replace_prompt( digest, source_seconds=source_seconds, output_seconds=output_seconds, ) @@ -674,21 +664,9 @@ def run_replace_digest(task) -> None: next_payload["digest_pending"] = False next_payload["digest_text"] = digest[:32000] next_payload["prompt"] = prompt - if plan: - next_payload["shot_plan"] = [ - { - "index": item["index"], - "seconds": item["seconds"], - "prompt": item["prompt"], - "status": "queued", - "provider_task_id": "", - "media": "", - "tokens": 0, - } - for item in plan - ] - next_payload["shot_total"] = len(plan) - next_payload["duration"] = sum(item["seconds"] for item in plan) + next_payload["references"] = _image_refs(next_payload.get("references") or []) + next_payload.pop("shot_plan", None) + next_payload.pop("shot_total", None) locked.request_payload = next_payload locked.save(update_fields=["request_payload", "updated_at"]) task = locked @@ -704,6 +682,11 @@ def run_replace_digest(task) -> None: def start_pending_replace_shots(task): + """旧逐镜路径已下线。一律整片一次出,且不传用户原片。""" + return start_replace_generation(task) + + +def _legacy_start_pending_replace_shots(task): """CREATED → 按全部镜头总时长预留积分 → 提交第一镜。""" from apps.billing.services.ledger import reserve_credit @@ -1545,33 +1528,18 @@ def _owned_ref(asset: Asset, *, kind: str, role: str, label: str) -> dict: return ref -def _seedance_references(payload: dict) -> list: - """出片用的引用:原片 + 已过审的商品图/角色图。 - - 审核列表里只有图。原片走直链(source=upload),不当三库人物素材再送一遍审。 - 2.0 就是靠这条视频锁切镜,2.5 同样必须带上。 - """ - images = [ +def _image_refs(references) -> list: + """出片/审核只用图。用户原片哪怕还躺在旧 payload 里,也一律丢掉。""" + return [ dict(item) - for item in (payload.get("references") or []) + for item in (references or []) if (item or {}).get("type") != "video" ] - video = payload.get("digest_source_ref") - if not isinstance(video, dict) or not (video.get("url") or video.get("resolved_url")): - video = next( - (item for item in (payload.get("references") or []) if (item or {}).get("type") == "video"), - None, - ) - if not isinstance(video, dict): - return images - copy_ref = dict(video) - copy_ref.update({ - "type": "video", - "role": "reference_video", - "label": copy_ref.get("label") or "参考视频", - "source": "upload", - }) - return [copy_ref, *images] + + +def _seedance_references(payload: dict) -> list: + """出片只带商品图/角色图。用户上传的原片不传 Seedance,避免素材审核报错。""" + return _image_refs(payload.get("references") or []) def _library_image_ref(asset: Asset, *, team, label: str) -> dict: diff --git a/core/backend/apps/ai/views.py b/core/backend/apps/ai/views.py index 6b8b2f8..47af8d5 100644 --- a/core/backend/apps/ai/views.py +++ b/core/backend/apps/ai/views.py @@ -1082,10 +1082,7 @@ _FREE_REF_VIDEO_TYPES = {"video/mp4", "video/quicktime"} _FREE_REF_AUDIO_TYPES = {"audio/mpeg", "audio/wav", "audio/x-wav", "audio/wave"} _FREE_REF_IMAGE_MAX = 30 * 1024 * 1024 _FREE_REF_VIDEO_MAX = 50 * 1024 * 1024 -# 视频复刻·商品:参考视频只喂给提炼模型(不发火山),没有 15 秒的硬限制, -# 按「提炼提示词」页那套放宽到 60 秒 / 200MB。自由创作和角色复刻仍走上面的 15 秒。 -# 拿这个 purpose 传上来的长视频,若被拿去自由创作/角色复刻,下游 build_content_items -# 与 submit_video_replace 各自还有 15 秒校验兜底,越不过去。 +# 视频复刻上传:文件可以到 200MB,时长按 Seedance 2.5 的 30 秒收口。原片只用来提炼。 _REPLACE_SOURCE_PURPOSE = "video_replace_product" _REPLACE_SOURCE_VIDEO_MAX = 200 * 1024 * 1024 _REPLACE_SOURCE_DURATION_MAX = 30.5 diff --git a/core/frontend/src/routes/video-replace.tsx b/core/frontend/src/routes/video-replace.tsx index 1fc8895..05bcff1 100644 --- a/core/frontend/src/routes/video-replace.tsx +++ b/core/frontend/src/routes/video-replace.tsx @@ -23,7 +23,6 @@ import { OverlayPortal, useBodyScrollLock } from "../components/overlays"; import { useFileDrop } from "../components/use-file-drop"; import { DEFAULT_BILLING_RATES, - FC_MODELS, IMAGE_TYPES, MAX_IMAGES, checkRefFile, @@ -38,11 +37,11 @@ const JOB_KEY = "airshelf:video-replace-job"; const MODE_KEY = "airshelf:video-replace-mode"; const CHARACTER_MARK = "[视频复刻·角色]"; const VIDEO_ACCEPT = "video/mp4,video/quicktime"; -// 复刻(商品 / 角色)统一走 Seedance 2.5,单次能出 30 秒,参考视频也按 30 秒收口。 +// 复刻固定 Seedance 2.5:参考视频只用来提炼分镜稿,成片最长 30 秒。 +const REPLACE_SEEDANCE_MODEL = "doubao-seedance-2-5-260628"; const PRODUCT_SOURCE_PURPOSE = "video_replace_product"; const REF_SECONDS_MAX = { product: 30, character: 30 } as const; const REF_BYTES_MAX = { product: 200 * 1024 * 1024, character: 200 * 1024 * 1024 } as const; -// Seedance 2.5 单次出片上限。参考视频更长时不报错,成片按这个截断。 const SEEDANCE_MAX_OUTPUT_SECONDS = 30; type ProductSource = "library" | "temporary" | ""; @@ -54,7 +53,7 @@ const REPLACE_MODE_COPY = { targetLabel: "商品", targetStep: "2. 选择自己的商品", videoEmpty: "系统会先把参考视频拆成分镜稿,再照着它换成你的商品", - videoReady: "视频已就绪,将先提炼分镜稿再逐镜复刻", + videoReady: "视频已就绪,将先提炼分镜稿再出片", libraryTitle: "从商品库选择", libraryEmpty: "选择已创建的商品", temporaryTitle: "临时上传素材", @@ -62,7 +61,7 @@ const REPLACE_MODE_COPY = { temporaryNoun: "商品图", temporaryFallback: "临时商品素材", generatingTitle: "正在进行商品复刻", - generatingCopy: "正在按分镜一镜一镜还原,并换上你的商品", + generatingCopy: "正在按分镜稿出片,并换上你的商品", reviewingTitle: "正在审核参考素材", reviewingCopy: "参考图需先通过合规审核,通过后自动开始复刻", digestingTitle: "正在提炼参考视频", @@ -81,7 +80,7 @@ const REPLACE_MODE_COPY = { targetLabel: "角色", targetStep: "2. 选择自己的角色", videoEmpty: "系统会先把参考视频拆成分镜稿,再照着它换成你的角色", - videoReady: "视频已就绪,将先提炼分镜稿再逐镜复刻", + videoReady: "视频已就绪,将先提炼分镜稿再出片", libraryTitle: "从人物库选择", libraryEmpty: "选择已创建的人物", temporaryTitle: "临时上传角色", @@ -89,7 +88,7 @@ const REPLACE_MODE_COPY = { temporaryNoun: "角色参考图", temporaryFallback: "临时角色素材", generatingTitle: "正在进行角色复刻", - generatingCopy: "正在按分镜一镜一镜还原,并换上你的角色", + generatingCopy: "正在按分镜稿出片,并换上你的角色", reviewingTitle: "正在审核参考素材", reviewingCopy: "参考图需先通过合规审核,通过后自动开始复刻", digestingTitle: "正在提炼参考视频", @@ -178,7 +177,6 @@ function formatClock(seconds: number) { function clampDuration(seconds: number) { const rounded = Math.round(Number(seconds) || SEEDANCE_MAX_OUTPUT_SECONDS); - // 60 秒参考视频不是错误:成片取火山能出的最长,分镜稿由模型压缩改编。 return Math.min(SEEDANCE_MAX_OUTPUT_SECONDS, Math.max(4, rounded || SEEDANCE_MAX_OUTPUT_SECONDS)); } @@ -326,7 +324,7 @@ export function VideoReplacePage({ [modelConfigs], ); const preferredModel = useMemo( - () => videoConfigs.find((config) => config.name === FC_MODELS[0].name) || videoConfigs[0], + () => videoConfigs.find((config) => config.name === REPLACE_SEEDANCE_MODEL) || videoConfigs[0], [videoConfigs], ); @@ -359,7 +357,7 @@ export function VideoReplacePage({ name: item.label || `${copy.temporaryNoun}${index + 1}`, })); const aspectRatio = ratioFromSize(videoMeta.width, videoMeta.height); - const outputDuration = clampDuration(videoMeta.duration || 15); + const outputDuration = clampDuration(videoMeta.duration || SEEDANCE_MAX_OUTPUT_SECONDS); const estimated = estimateCost(preferredModel, { ratio: aspectRatio, resolution: "720p", @@ -620,8 +618,7 @@ export function VideoReplacePage({ const switchReplaceMode = (next: ReplaceMode) => { if (next === replaceMode || generating) return; - // 商品复刻能传 60 秒,角色复刻只能 15 秒。带着超长视频切过去会一路走到提交才报错, - // 这里直接清掉并说明原因。 + // 两种模式都按 30 秒收口。带着超长视频切过去会一路走到提交才报错,这里直接清掉。 const tooLongForNext = (videoMeta.duration || 0) > REF_SECONDS_MAX[next] + 0.5; if (tooLongForNext) { setVideoFile(null);