From 6b6146e5955069286e3326436aa992fa0eb1fb17 Mon Sep 17 00:00:00 2001 From: "Azmat@qq.com" Date: Fri, 28 Aug 2026 18:42:28 +0800 Subject: [PATCH] =?UTF-8?q?=E4=BC=98=E5=8C=96=E8=A7=86=E9=A2=91=E5=A4=8D?= =?UTF-8?q?=E5=88=BB=E5=92=8C=E5=95=86=E5=93=81=E5=86=85=E5=AE=B9=E5=A4=A7?= =?UTF-8?q?=E5=B0=8F?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- core/backend/apps/ai/free_video.py | 20 +- core/backend/apps/ai/tasks.py | 12 +- core/backend/apps/ai/test_video_replace.py | 275 ++++- core/backend/apps/ai/video_replace.py | 1069 ++++++++++++++--- core/backend/apps/ai/views.py | 13 +- core/backend/apps/assets/image_guard.py | 39 + core/backend/apps/assets/review.py | 34 + core/backend/apps/assets/tests.py | 66 + core/backend/apps/assets/views.py | 12 +- .../0005_product_ordering_created_at.py | 15 + core/backend/apps/products/models.py | 1 + core/backend/apps/products/tests.py | 22 +- core/backend/apps/products/views.py | 18 +- core/backend/apps/projects/views.py | 21 +- core/frontend/src/library-page.css | 64 +- core/frontend/src/models-page.css | 80 +- core/frontend/src/products-page.css | 94 +- .../src/routes/admin/admin-reviews.tsx | 48 +- core/frontend/src/routes/video-replace.tsx | 32 +- core/frontend/src/types.ts | 4 +- 20 files changed, 1557 insertions(+), 382 deletions(-) create mode 100644 core/backend/apps/assets/image_guard.py create mode 100644 core/backend/apps/products/migrations/0005_product_ordering_created_at.py diff --git a/core/backend/apps/ai/free_video.py b/core/backend/apps/ai/free_video.py index f03554e..61e0404 100644 --- a/core/backend/apps/ai/free_video.py +++ b/core/backend/apps/ai/free_video.py @@ -811,15 +811,17 @@ def _notify_failure(task: AITask, *, raw: str, hint: str) -> None: ) -def _store_free_video_media(*, task: AITask, media: str) -> Asset: - """下载火山结果 → 转存 TOS(火山原始 URL 仅 7 天有效)→ 建 Asset(FREE_CREATE,自动入库) - + ffmpeg 抽首帧封面挂同 Asset 非主文件。""" - fileobj, content_type = VolcanoArkProvider.media_to_bytes(media) - if "video" not in content_type: - content_type = "video/mp4" - # 先取字节再上传:boto3 upload_fileobj 完成后会 close 掉 BytesIO,之后 getvalue() 直接抛 - # "I/O operation on closed file",封面抽帧就永远做不了(实测踩坑)。 - video_bytes = fileobj.getvalue() if isinstance(fileobj, BytesIO) else b"" +def _store_free_video_media(*, task: AITask, media: str = "", video_bytes: bytes | None = None) -> Asset: + """下载火山结果(或已拼好的字节) → 转存 TOS → 建 Asset + ffmpeg 抽首帧封面。""" + content_type = "video/mp4" + if video_bytes is None: + fileobj, content_type = VolcanoArkProvider.media_to_bytes(media) + if "video" not in content_type: + content_type = "video/mp4" + # 先取字节再上传:boto3 upload_fileobj 完成后会 close 掉 BytesIO,之后 getvalue() 直接抛 + # "I/O operation on closed file",封面抽帧就永远做不了(实测踩坑)。 + video_bytes = fileobj.getvalue() if isinstance(fileobj, BytesIO) else b"" + fileobj = BytesIO(video_bytes) asset_id = uuid.uuid4() object_key = f"teams/{task.team_id}/free-create/{asset_id}.mp4" stored = TosStorage().upload_fileobj(fileobj=fileobj, object_key=object_key, content_type=content_type) diff --git a/core/backend/apps/ai/tasks.py b/core/backend/apps/ai/tasks.py index 303e2e3..c65534d 100644 --- a/core/backend/apps/ai/tasks.py +++ b/core/backend/apps/ai/tasks.py @@ -43,8 +43,8 @@ def run_video_digest_task(self, task_id: str) -> str: @app.task(bind=True, max_retries=0) def run_video_replace_digest_task(self, task_id: str) -> str: - """商品复刻的第一道工序(参考视频 → 分镜稿,Gemini 半分钟起)在 worker 内跑。 - 失败自己把任务收成 FAILED(见 run_replace_digest),故 max_retries=0,不向上抛重试。""" + """Worker:视频复刻第一道工序(参考视频 → 分镜稿,Gemini 半分钟起)在 worker 内跑。 + 商品和角色共用。失败自己把任务收成 FAILED(见 run_replace_digest),故 max_retries=0,不向上抛重试。""" from apps.ai.models import AITask from apps.ai.video_replace import run_replace_digest @@ -97,7 +97,7 @@ def poll_free_video_task(self, task_id: str, attempt: int = 0) -> str: if task is None: return task_id try: - if is_video_replace_task(task) and task.status == AITask.Status.CREATED: + if is_video_replace_task(task): task = advance_video_replace(task) else: task = finalize_free_video(task=task) @@ -110,9 +110,11 @@ def poll_free_video_task(self, task_id: str, attempt: int = 0) -> str: if getattr(dj_settings, "CELERY_TASK_ALWAYS_EAGER", False): return task_id - if task.status == AITask.Status.CREATED and attempt < 60: + # 复刻逐镜生成可能跑 8 段 × 数分钟,60 次(约 30 分钟)不够,拉到约 90 分钟。 + poll_limit = 180 if is_video_replace_task(task) else 60 + if task.status == AITask.Status.CREATED and attempt < poll_limit: poll_free_video_task.apply_async(args=[task_id, attempt + 1], countdown=8 if attempt < 12 else 30) - elif task.status in (AITask.Status.SUBMITTED, AITask.Status.POLLING) and attempt < 60: + elif task.status in (AITask.Status.SUBMITTED, AITask.Status.POLLING) and attempt < poll_limit: poll_free_video_task.apply_async(args=[task_id, attempt + 1], countdown=30) return task_id diff --git a/core/backend/apps/ai/test_video_replace.py b/core/backend/apps/ai/test_video_replace.py index 1ff1a93..5a9084f 100644 --- a/core/backend/apps/ai/test_video_replace.py +++ b/core/backend/apps/ai/test_video_replace.py @@ -20,6 +20,7 @@ from apps.ai.video_replace import ( REVIEW_FAILED, REVIEW_UNAVAILABLE, advance_video_replace, + build_shot_plan, compact_digest_for_video, run_replace_digest, submit_video_replace, @@ -72,6 +73,52 @@ DIGEST_SAMPLE = """片名:《晨间一杯》 - 第 2 镜标签文字看不清,请校对品牌名。 """ +DIGEST_FOUR = """片名:加长 +画面比例:9:16 竖屏 +整体风格:写实 +主线:四段卖点。 + +【镜头 01】 +时间:00:00-00:05 +时长:5秒 +景别:中景 +机位:平视 +运镜:固定 +画面:第一镜开场。 +人物动作:举手。 +台词/旁白:第一句。 + +【镜头 02】 +时间:00:05-00:10 +时长:5秒 +景别:近景 +机位:平视 +运镜:固定 +画面:第二镜展示。 +人物动作:转瓶。 +台词/旁白:第二句。 + +【镜头 03】 +时间:00:10-00:15 +时长:5秒 +景别:特写 +机位:平视 +运镜:推近 +画面:第三镜特写。 +人物动作:按下泵头。 +台词/旁白:第三句。 + +【镜头 04】 +时间:00:15-00:20 +时长:5秒 +景别:中近景 +机位:平视 +运镜:固定 +画面:第四镜收束。 +人物动作:微笑。 +台词/旁白:第四句。 +""" + def _asset(team, user, *, kind=Asset.Type.IMAGE, name="素材", duration_ms=None, preview="http://tos/1.png", review_status="active", review_remote_id=None): asset = Asset.objects.create( @@ -122,13 +169,13 @@ class SubmitVideoReplaceTests(TestCase): "model": STANDARD, "aspect_ratio": "9:16", "resolution": "480p", - "duration": 4, + "duration": 8, } params.update(over) return submit_video_replace(team=self.team, user=self.user, params=params) - def test_product_submit_waits_for_digest_and_keeps_video_out_of_refs(self): - """商品复刻第一步:秒回 CREATED,参考视频只留作拆解源,不进火山 references。""" + def test_product_submit_waits_for_digest_and_keeps_video_out_of_review_refs(self): + """商品复刻第一步:秒回 CREATED。审核列表只有商品图;原片快照另存,出片时再带上。""" task = self._submit() self.assertEqual(task.status, AITask.Status.CREATED) payload = task.request_payload @@ -145,8 +192,8 @@ class SubmitVideoReplaceTests(TestCase): self.assertFalse(self.provider.create_video_task.called) self.assertEqual(CreditReservation.objects.filter(task=task).count(), 0) - def test_product_digest_becomes_prompt_and_only_images_go_to_ark(self): - """第二步:worker 拆完 → 分镜稿变提示词 → 只带商品图提交火山。""" + def test_product_digest_becomes_prompt_and_video_goes_to_ark(self): + """第二步:worker 拆完 → 按镜拆成生成计划 → 原片 + 商品图一起提交第一镜。""" task = self._submit() with patch( "apps.ai.video_digest.digest_asset_video", @@ -158,19 +205,28 @@ 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) prompt = payload["prompt"] - self.assertIn("【镜头 01】", prompt) # 分镜稿骨架进了提示词 - self.assertIn("倒进玻璃杯", prompt) # 画面描述保留 - self.assertIn("@目标商品", prompt) # 商品图引用锚点在 - self.assertIn("净颜精华", prompt) # 口播里的旧商品名改说自家商品 - self.assertNotIn("【拆解存疑】", prompt) # 给用户校对的段落不喂模型 - self.assertNotIn("字幕:", prompt) # 花字记录不喂,免得模型画上去 + self.assertIn("@参考视频", prompt) # 2.0 锁切镜的锚点 + self.assertIn("第 1/", prompt) # 只喂当前这一镜 + self.assertIn("倒进玻璃杯", prompt) # 提炼原文画面保留 + self.assertIn("台词/旁白", prompt) # 不改写成「口播」 + self.assertIn("@目标商品", prompt) + self.assertIn("净颜精华", 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) 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.assertNotIn("reference_video", roles) # 参考视频不再发给火山 - self.assertNotIn("video_url", [item.get("type") for item in content]) + self.assertIn("reference_video", roles) + self.assertIn("video_url", [item.get("type") for item in content]) def _retire(self, task): """把任务收成终态,好让单飞闸放行下一次提交。""" @@ -206,7 +262,7 @@ class SubmitVideoReplaceTests(TestCase): run_replace_digest(task) task.refresh_from_db() self.assertEqual(task.status, AITask.Status.SUBMITTED) - self.assertIn("【镜头 01】", task.request_payload["prompt"]) + self.assertIn("第 1/", task.request_payload["prompt"]) self.assertIn("@目标商品", task.request_payload["prompt"]) def test_replace_digest_prompt_matches_standalone(self): @@ -381,7 +437,7 @@ class SubmitVideoReplaceTests(TestCase): tri.save(update_fields=["metadata"]) return tri - def _run_digest(self, task): + def _run_digest(self, task, digest=DIGEST_SAMPLE): from apps.ai import video_digest text_model = ModelConfig.objects.filter(capability="text", status="active").first() @@ -391,7 +447,7 @@ class SubmitVideoReplaceTests(TestCase): return_value=(None, [], 8.0, {"width": 720, "height": 1280, "file_name": "参考.mp4"}), ), \ patch.object(video_digest, "resolve_digest_model_config", return_value=text_model), \ - patch("apps.ai.services._collect_extract_text", return_value=(DIGEST_SAMPLE, {})): + patch("apps.ai.services._collect_extract_text", return_value=(digest, {})): run_replace_digest(task) task.refresh_from_db() return task @@ -408,7 +464,6 @@ class SubmitVideoReplaceTests(TestCase): self.assertEqual(task.status, AITask.Status.SUBMITTED) prompt = task.request_payload["prompt"] self.assertIn("@目标商品三视图", prompt) - self.assertIn("白底多角度图", prompt) content = self.provider.create_video_task.call_args.kwargs.get("content_items") or [] image_items = [item for item in content if item.get("type") == "image_url"] @@ -457,12 +512,100 @@ class SubmitVideoReplaceTests(TestCase): self.assertEqual(len(refs), MAX_IMAGES) self.assertEqual(refs[-1]["label"], "目标商品三视图") - def test_compact_digest_trims_human_only_sections(self): - compact = compact_digest_for_video(DIGEST_SAMPLE) - self.assertIn("【镜头 02】", compact) - self.assertIn("台词/旁白:", compact) - self.assertNotIn("字幕:", compact) - self.assertNotIn("【拆解存疑】", compact) + 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_unparseable_digest_falls_back_to_raw_text(self): + """模型没按 skill 格式吐时,宁可原文照传也不能把镜头信息弄丢。""" + messy = "这条片子讲了一个女生的早晨。\n她拿起瓶子。\n【拆解存疑】\n- 看不清" + out = compact_digest_for_video(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_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_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"] + task = self._run_digest(self._submit()) + self.assertEqual(task.provider_task_id, "ark-1") + self.assertEqual(self.provider.create_video_task.call_count, 1) + + 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_character_library_and_temp_are_exclusive(self): portrait = _asset(self.team, self.user, name="模特.png", preview="http://tos/model.png") @@ -472,6 +615,10 @@ class SubmitVideoReplaceTests(TestCase): task = self._submit(replace_mode="character", product_id="", model_id=str(model.id)) self.assertEqual(task.request_payload["replace_mode"], "character") self.assertEqual(task.request_payload["subject_name"], "薇薇") + self.assertTrue(task.request_payload["digest_pending"]) + labels = [item["label"] for item in task.request_payload["references"]] + self.assertNotIn("参考视频", labels) + self.assertIn("目标角色", labels) def test_both_modes_accept_up_to_30s(self): """商品和角色都走 Seedance 2.5(单次能出 30 秒),参考视频统一按 30 秒收口。""" @@ -495,29 +642,31 @@ class SubmitVideoReplaceTests(TestCase): with self.assertRaisesMessage(ValueError, "不能超过 30 秒"): self._submit(replace_mode=mode, video_asset_id=str(too_long.id), **extra) - def test_long_source_asks_model_to_condense(self): - """60 秒参考稿 + 15 秒成片:必须让模型压缩改编,不然会照着拍到一半戛然而止。""" + def test_long_source_drops_trailing_shots(self): + """参考片比成片长很多时整镜省略尾镜,不要压缩画面。""" long_video = _asset(self.team, self.user, kind=Asset.Type.VIDEO, name="半分钟.mp4", duration_ms=28000) - # 显式要一段短成片:参考 28 秒、成片 10 秒,差得多才要求模型压缩改编 - task = self._run_digest(self._submit(video_asset_id=str(long_video.id), duration=10)) - prompt = task.request_payload["prompt"] - self.assertIn("压缩改编", prompt) - self.assertIn("28 秒", prompt) - self.assertIn("10 秒", prompt) + 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"]) - def test_short_source_skips_condense_note(self): - """参考视频和成片时长差不多时别啰嗦。""" + def test_short_source_keeps_all_shots(self): + """时长一致时保留全部镜头。""" task = self._run_digest(self._submit(duration=None)) # setUp 的参考视频 8 秒 → 成片也 8 秒 - self.assertNotIn("压缩改编", task.request_payload["prompt"]) + self.assertEqual(len(task.request_payload["shot_plan"]), 2) + self.assertNotIn("时长不一致时的取舍", task.request_payload["prompt"]) + self.assertNotIn("省略次要镜头", task.request_payload["prompt"]) - def test_prompt_always_pins_character_consistency(self): - """上传图里可能有人物(服饰模特图/真人手持图),要锁住人物一致; - 没有人物时也要说清楚「别从商品图凭空套脸」。""" + def test_prompt_copies_source_video_instead_of_rewriting_a_script(self): + """切镜靠 @参考视频,提示词只钉「换商品」+ 这一镜原文,不要再扩写成导演长文。""" prompt = self._run_digest(self._submit()).request_payload["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) + self.assertNotIn("不要从商品图凭空套脸", prompt) def test_accepts_15s_video_with_probe_slack(self): from apps.ai.media_probe import REF_DURATION_MAX @@ -658,8 +807,7 @@ class VideoReplaceApiTests(TestCase): class VideoReplaceReviewGateTests(TestCase): - """审核闸走角色复刻:只有它才把参考视频直传火山,需要真人合规审核。 - 商品复刻改成先提炼分镜稿,参考视频不进火山,自然也不再送审。""" + """审核闸只盯人物图/商品图。原片出片时直传火山锁切镜,不进人物素材库送审。""" def setUp(self): self.user = User.objects.create_user(username="vrrev", password="p") @@ -704,54 +852,69 @@ class VideoReplaceReviewGateTests(TestCase): def test_unreviewed_assets_create_pending_task_without_charging(self): with patch("apps.assets.assets_client.is_enabled", return_value=True), patch( "apps.assets.review.get_or_create_team_group" - ) as grp, patch("apps.assets.assets_client.create_asset", side_effect=["Asset-vid", "Asset-img"]) as create, patch( + ) as grp, patch("apps.assets.assets_client.create_asset", return_value="Asset-img") as create, patch( "apps.assets.assets_client.get_asset", return_value={"Status": "Processing"} ): grp.return_value = MagicMock(remote_group_id="Group-1") task = self._submit() self.assertEqual(task.status, AITask.Status.CREATED) - self.assertTrue((task.request_payload or {}).get("review_pending")) + self.assertTrue((task.request_payload or {}).get("digest_pending")) self.assertEqual(CreditReservation.objects.filter(task=task).count(), 0) self.assertFalse(self.provider.create_video_task.called) passed_types = [call.kwargs.get("asset_type") for call in create.call_args_list] - self.assertIn("Video", passed_types) - self.assertIn("Image", passed_types) + self.assertEqual(passed_types, ["Image"]) + labels = [item["label"] for item in (task.request_payload or {}).get("references") or []] + self.assertNotIn("参考视频", labels) def test_advance_starts_generation_after_review_passes(self): with patch("apps.assets.assets_client.is_enabled", return_value=True), patch( "apps.assets.review.get_or_create_team_group" - ) as grp, patch("apps.assets.assets_client.create_asset", side_effect=["Asset-vid", "Asset-img"]), patch( + ) as grp, patch("apps.assets.assets_client.create_asset", return_value="Asset-img"), patch( "apps.assets.assets_client.get_asset", return_value={"Status": "Processing"} ): grp.return_value = MagicMock(remote_group_id="Group-1") task = self._submit() - self.video.refresh_from_db() self.image.refresh_from_db() - self.video.review_status = "active" - self.video.review_remote_id = "asset-vid" - self.video.save(update_fields=["review_status", "review_remote_id"]) self.image.review_status = "active" self.image.review_remote_id = "asset-img" self.image.save(update_fields=["review_status", "review_remote_id"]) with patch("apps.assets.assets_client.is_enabled", return_value=True), patch( "apps.assets.assets_client.get_asset", return_value={"Status": "Active"} + ), patch( + "apps.ai.video_digest.digest_asset_video", + return_value=(DIGEST_SAMPLE, {"digest_shots": 2}), ): - task = advance_video_replace(task) + run_replace_digest(task) + task.refresh_from_db() self.assertEqual(task.status, AITask.Status.SUBMITTED) self.assertTrue(CreditReservation.objects.filter(task=task).exists()) self.assertTrue(self.provider.create_video_task.called) content = self.provider.create_video_task.call_args.kwargs.get("content_items") or [] - urls = [item.get("video_url", item.get("image_url", {})).get("url") for item in content] - self.assertTrue(all(str(url).startswith("asset://") for url in urls if url)) + types = [item.get("type") for item in content] + self.assertIn("video_url", types) + image_urls = [ + (item.get("image_url") or {}).get("url") + for item in content if item.get("type") == "image_url" + ] + self.assertTrue(all(str(url).startswith("asset://") for url in image_urls if url)) def test_advance_fails_review_without_charging(self): with patch("apps.assets.assets_client.is_enabled", return_value=True), patch( "apps.assets.review.get_or_create_team_group" - ) as grp, patch("apps.assets.assets_client.create_asset", side_effect=["Asset-vid", "Asset-img"]), patch( + ) as grp, patch("apps.assets.assets_client.create_asset", return_value="Asset-img"), patch( "apps.assets.assets_client.get_asset", return_value={"Status": "Processing"} ): grp.return_value = MagicMock(remote_group_id="Group-1") task = self._submit() + with patch("apps.assets.assets_client.is_enabled", return_value=True), patch( + "apps.assets.assets_client.get_asset", return_value={"Status": "Processing"} + ), patch( + "apps.ai.video_digest.digest_asset_video", + return_value=(DIGEST_SAMPLE, {"digest_shots": 2}), + ): + run_replace_digest(task) + task.refresh_from_db() + self.assertEqual(task.status, AITask.Status.CREATED) with patch("apps.assets.assets_client.is_enabled", return_value=True), patch( "apps.assets.assets_client.get_asset", return_value={"Status": "Failed", "ErrorMessage": "real person"} ): diff --git a/core/backend/apps/ai/video_replace.py b/core/backend/apps/ai/video_replace.py index 194a953..c63faf7 100644 --- a/core/backend/apps/ai/video_replace.py +++ b/core/backend/apps/ai/video_replace.py @@ -1,19 +1,22 @@ -"""视频复刻:参考视频 + 商品图/人物图 → Seedance 换商品或换角色。 +"""视频复刻:参考视频 + 商品图/人物图 → Seedance 2.5 换商品或换角色。 -不新建任务类型、不接检测/抠图。提交仍走 submit_free_video,只把 -feature=video_replace 和 replace_mode 写进 payload,提示词由后端写死。 +2.0 效果好是因为把 @参考视频 直接喂给火山锁切镜。2.5 单次能出 30 秒,模型换成 +2.5 即可;不要改写成一大段分镜散文再文生视频,否则切镜对不上、描写过头。 -真人参考必须先送火山素材库审核,过审后用 asset:// 生成;审核失败不扣费。 +流程:「提炼提示词」同一套接口拆出每镜时间轴 → 一镜一次 Seedance(仍带上原片) +→ 按原片顺序拼接。商品图/人物图过审后用 asset://;审核失败不扣费。 """ from __future__ import annotations import logging +import re import uuid from decimal import Decimal from django.conf import settings from django.db import transaction from django.db.models import Q +from django.utils import timezone from apps.assets.models import Asset, Model from apps.billing.models import CreditAccount @@ -44,9 +47,7 @@ LEGACY_PROMPT_PREFIX = "[视频复刻]" # 商品三视图在商品库是 standalone Asset(metadata.view=three_view + product_id), # 和项目内的 BaseAssetGroup 是两套存储,这里直接取商品库那份。 TRIVIEW_LABEL = "目标商品三视图" -# 复刻统一走 Seedance 2.5,单次能出 30 秒,所以参考视频也按 30 秒收口: -# 商品模式的参考视频只喂提炼模型,角色模式直传火山,两边都不需要超过成片上限的素材。 -# 半秒容差同 media_probe 的老规矩(30 秒成片常被探成 30.02)。 +# 复刻走 Seedance 2.5(单次最长 30 秒)。半秒容差同 media_probe(30 秒成片常被探成 30.02)。 REPLACE_REF_DURATION_MAX = 30.5 REVIEW_UNAVAILABLE = "素材审核服务暂不可用,请稍后重试" REVIEW_FAILED = "参考素材未通过真人合规审核,请更换视频或图片后重试" @@ -77,55 +78,14 @@ def get_inflight_video_replace(team): .first() ) -# 商品复刻不再把参考视频直接丢给火山(实测换不动商品,出片跑偏),改走两步: -# 先用「提炼提示词」那一整套(同一份 SKILL.md + 同一个 Gemini 3.1 Pro)把参考视频拆成 -# 中文分镜稿,再把分镜稿当导演脚本、连同商品图一起交给 Seedance。 -# 角色复刻仍是「参考视频 + 角色图」直传,已验证有效,不要顺手一起改。 -PRODUCT_DIGEST_HEAD = ( - "下面是参考视频的完整分镜稿,它逐镜记录了这条片子的时间、景别、机位、运镜、画面、" - "人物动作与表情、台词旁白和声音。请把它当作导演脚本,按镜头顺序还原成一条新视频。" +# 2.0 就是这四句话 + @参考视频。不要再扩写成「镜头表 / 必须原样保持」长文, +# 2.5 会把多出来的描写当成新画面去演。 +PRODUCT_PROMPT = ( + "使用@参考视频作为镜头、节奏与口播氛围基准," + "将画面中需要替换的原商品完整替换为@目标商品的外观。" + "保留参考视频的人物、场景、镜头运动、剪辑节奏与口播氛围," + "商品外观、材质、包装必须与参考图一致,不要改变原片构图和人物表演。" ) -PRODUCT_DIGEST_TAIL = ( - "【复刻要求】\n" - "1. 严格按上面分镜稿的镜头顺序、时间分配、景别、机位、运镜和剪辑节奏还原全片,不要自行增删镜头。\n" - "2. 分镜稿里出现的原商品,全部替换成 @目标商品;商品的外形、颜色、材质、包装和标识必须与参考图完全一致," - "不要改造型、不要改配色、不要凭空补细节。\n" - "{triview}" - "3. 人物、场景、光线、色调、动作和表情按分镜稿保持不变,只换商品。\n" - " · 全片的人物必须自始至终是同一个人:五官、发型、体态和服装在所有镜头里保持一致," - "不要中途换脸、换人或换装。\n" - " · 参考图里如果有人物(例如服饰类的模特图、真人手持商品的图),画面中的人物就以那张参考图为准," - "五官、发型、体态和服装必须与之一致;参考图里只有商品、没有人物时,就按分镜稿描述的人物去演," - "不要从商品图上凭空套一张脸。\n" - "4. 台词/旁白按分镜稿逐字念出;原文提到旧商品名称的地方,改说「{subject}」。\n" - "5. 分镜稿里的「字幕」栏只是对原片的记录,不要把这些文字画到画面上。" - "{condense}" -) - -# 参考视频比成片长时才追加(火山单次出片上限 15 秒,60 秒参考稿必须压缩改编, -# 否则模型会照着分镜稿从头拍、拍到 15 秒戛然而止)。 -PRODUCT_CONDENSE_NOTE = ( - "\n6. 参考视频约 {source} 秒,而本次成片只有 {output} 秒:请按分镜稿的结构和节奏**压缩改编**," - "保留主线、最关键的卖点镜头和结尾收束,可以合并或省略次要镜头;" - "不要只拍开头几镜就停,也不要把画面加速成快放。" -) -# 商品库里有三视图时才追加这一条(没有就不提,免得模型去找一张不存在的图)。 -PRODUCT_TRIVIEW_NOTE = ( - " · @目标商品三视图 是这件商品的白底多角度图,用它确认商品的立体结构、比例和各个面的细节;" - "镜头转到任何角度,商品都要和三视图对得上。\n" -) - -# 拆解期间的占位提示词。真正的提示词在 worker 拆完后回写。 -DIGEST_PENDING_PROMPT = "正在提炼参考视频的分镜稿…" - -# 分镜稿是给人逐镜校对的导演稿,整篇塞给视频模型太长也太杂,按需瘦身: -# ·「字幕」栏是原片花字的记录,留着会诱导模型把字画到画面上,与全站「成片无字幕」冲突; -# ·【拆解存疑】是给用户校对用的,视频模型用不上。 -DIGEST_DROP_FIELDS = ("字幕",) -# 瘦身完还超长时再砍这几栏(信息量最低,砍掉不影响镜头结构)。 -DIGEST_TRIM_FIELDS = ("音效", "背景音乐", "备注") -DIGEST_TAIL_SECTION = "【拆解存疑】" -MAX_DIGEST_CHARS = 4000 CHARACTER_PROMPT = ( "使用@参考视频作为镜头、节奏与口播氛围基准," "将画面中需要替换的原人物完整替换为@目标角色。" @@ -133,22 +93,285 @@ CHARACTER_PROMPT = ( "角色五官、发型、体态必须与参考图一致,不要改变原片构图和商品展示。" ) +# 拆解期间的占位提示词。真正的提示词在 worker 拆完后回写。 +DIGEST_PENDING_PROMPT = "正在提炼参考视频的分镜稿…" -def _drop_digest_fields(lines: list[str], fields: tuple[str, ...]) -> list[str]: - prefixes = tuple(f"{name}:" for name in fields) - return [line for line in lines if not line.strip().startswith(prefixes)] +# 商品库里有三视图时才追加(没有就不提,免得模型去找一张不存在的图) +PRODUCT_TRIVIEW_NOTE = "商品各面以@目标商品三视图为准。" + +# 只在用户主动把成片调得比参考片短很多时才出现。时长一致时绝不出现 —— +# 它和「节奏一模一样」是互相打架的。 +PRODUCT_CONDENSE_NOTE = ( + "参考片约 {source} 秒,本次成片 {output} 秒:整镜省略次要镜头,不要快放、不要把两镜压成一镜。" +) + +SHOT_HEADER = "【镜头" +DIGEST_TAIL_SECTION = "【拆解存疑】" +# 整片设定里值得留的(顺序即输出顺序) +KEEP_HEADER_FIELDS = ("画面比例", "整体风格", "主线") +# 喂给 Seedance 的镜头原文栏位。字幕/音效/BGM/备注会诱使模型画花字、加戏,一律丢掉。 +SHOT_COPY_FIELDS = ("时间", "时长", "景别", "机位", "运镜", "画面", "人物动作", "人物表情", "台词/旁白") +SHOT_INLINE_FIELDS = ("时间", "时长", "景别", "机位", "运镜") +SHOT_DETAIL_FIELDS = (("画面", "画面"), ("人物动作", "动作"), ("人物表情", "表情"), ("台词/旁白", "口播")) +_EMPTY_VALUES = {"", "无", "不可见", "看不出", "看不清", "听不清"} +# 模型会写「不可见。」「无。」这种带句读的空值,比对前先剥掉尾部标点 +_TRAILING_PUNCT = "。.,,、;;::!!?? " -def compact_digest_for_video(digest_text: str) -> str: - """分镜稿 → 交给视频模型的精简版。保留镜头结构,砍掉给人看的部分。""" +def _is_empty_value(value: str) -> bool: + return value.strip().rstrip(_TRAILING_PUNCT) in _EMPTY_VALUES + + +def _parse_digest(digest_text: str) -> tuple[dict, list[dict]]: + """分镜稿纯文本 → (整片设定, 镜头列表)。解析不出镜头返回 ([], []),由调用方回落原文。""" body = (digest_text or "").strip() head, sep, _tail = body.partition(DIGEST_TAIL_SECTION) if sep: body = head.strip() - lines = _drop_digest_fields(body.split("\n"), DIGEST_DROP_FIELDS) - if sum(len(line) for line in lines) > MAX_DIGEST_CHARS: - lines = _drop_digest_fields(lines, DIGEST_TRIM_FIELDS) - return "\n".join(line for line in lines if line.strip()).strip() + + header: dict[str, str] = {} + shots: list[dict] = [] + current: dict | None = None + for raw in body.split("\n"): + line = raw.strip() + if not line: + continue + if line.startswith(SHOT_HEADER): + current = {} + shots.append(current) + continue + key, sep_char, value = line.partition(":") + if not sep_char: + continue + key, value = key.strip(), value.strip() + if current is None: + header[key] = value + else: + current[key] = value + return header, [shot for shot in shots if shot] + + +def _render_shot_table(header: dict, shots: list[dict]) -> str: + """镜头表:每镜一行摘要(时间/景别/机位/运镜)+ 画面、动作、表情、口播四行细节。 + 空值(无 / 不可见)整行省掉 —— 反复出现的「无」只会稀释真正的指令。""" + lines: list[str] = ["【整片设定】"] + for field in KEEP_HEADER_FIELDS: + value = (header.get(field) or "").strip() + if not _is_empty_value(value): + lines.append(f"{field}:{value}") + lines.append(f"镜头数:{len(shots)} 镜(下面逐镜照拍,不要增删、不要改顺序)") + + lines.append("") + lines.append("【逐镜还原】") + for index, shot in enumerate(shots, start=1): + inline = [shot.get(f, "").strip() for f in SHOT_INLINE_FIELDS] + inline = [v for v in inline if not _is_empty_value(v)] + lines.append(f"镜头 {index:02d} | " + " | ".join(inline) if inline else f"镜头 {index:02d}") + for field, label in SHOT_DETAIL_FIELDS: + value = (shot.get(field) or "").strip() + if not _is_empty_value(value): + lines.append(f" {label}:{value}") + return "\n".join(lines) + + +# —— 逐镜生成 —— +# 2.5 一次塞整条 30 秒会自己切镜。拆成一镜一次,每段只还原原片对应时间, +# 并且把 @参考视频 一起带上(2.0 锁切镜的那一招),再按顺序拼起来。 +MIN_SHOT_SECONDS = 4 # 火山单次出片下限 +MAX_CLIP_SECONDS = 30 # Seedance 2.5 单次上限;单镜再长也一次出,避免凭空切一刀 +MAX_SHOTS = 12 # 再多就是积分黑洞,也超出用户预期 +_CLOCK_RE = re.compile(r"(\d{1,2}):(\d{2})") + + +def _raw_shot_seconds(shot: dict) -> int: + """分镜稿里这一镜多长,不做上下限裁切。优先「时长」,其次用「时间」区间算。""" + raw = (shot.get("时长") or "").strip() + digits = re.sub(r"[^0-9]", "", raw) + if digits: + return max(1, int(digits)) + stamps = _CLOCK_RE.findall(shot.get("时间") or "") + if len(stamps) >= 2: + start = int(stamps[0][0]) * 60 + int(stamps[0][1]) + end = int(stamps[1][0]) * 60 + int(stamps[1][1]) + if end > start: + return end - start + return 5 + + +def _clip_seconds(raw: int) -> int: + return max(MIN_SHOT_SECONDS, min(MAX_CLIP_SECONDS, int(raw or 0) or MIN_SHOT_SECONDS)) + + +def _pack_shot_groups(shots: list[dict]) -> list[list[dict]]: + """不足 4 秒的相邻镜合并成一次出片,避免火山拒单,也避免把 2 秒镜硬拉成 4 秒。""" + groups: list[list[dict]] = [] + current: list[dict] = [] + current_sec = 0 + for shot in shots[:MAX_SHOTS]: + sec = _raw_shot_seconds(shot) + if current and current_sec < MIN_SHOT_SECONDS: + current.append(shot) + current_sec += sec + continue + if current: + groups.append(current) + current = [shot] + current_sec = sec + if current: + if groups and current_sec < MIN_SHOT_SECONDS: + groups[-1].extend(current) + else: + groups.append(current) + return groups + + +def _trim_groups_to_output(groups: list[list[dict]], output_seconds: int) -> list[list[dict]]: + """用户把成片要得比参考片短很多时,整组省略尾镜,不要压缩画面。""" + if not output_seconds or not groups: + return groups + total = sum(_clip_seconds(sum(_raw_shot_seconds(s) for s in group)) for group in groups) + if total - output_seconds < 3: + return groups + kept: list[list[dict]] = [] + acc = 0 + for group in groups: + if acc >= output_seconds: + break + kept.append(group) + acc += _clip_seconds(sum(_raw_shot_seconds(s) for s in group)) + return kept or groups[:1] + + +def _verbatim_shot_block(shot: dict) -> str: + """提炼稿这一镜的原文栏位,不改写、不换标签名。""" + lines = [] + for field in SHOT_COPY_FIELDS: + value = (shot.get(field) or "").strip() + if not _is_empty_value(value): + lines.append(f"{field}:{value}") + return "\n".join(lines) + + +def _shot_clock(shot: dict) -> str: + clock = (shot.get("时间") or "").strip() + return clock if clock and not _is_empty_value(clock) else "" + + +def build_shot_plan( + digest_text: str, + subject_name: str, + *, + has_triview: bool = False, + replace_mode: str = "product", + output_seconds: int = 0, +) -> list[dict]: + """分镜稿 → 逐镜生成计划。解析不出镜头返回 [],调用方回落单次生成。""" + _header, shots = _parse_digest(digest_text) + if not shots: + return [] + groups = _trim_groups_to_output(_pack_shot_groups(shots), output_seconds) + total = len(groups) + plan = [] + for index, group in enumerate(groups, start=1): + seconds = _clip_seconds(sum(_raw_shot_seconds(shot) for shot in group)) + plan.append({ + "index": index, + "seconds": seconds, + "prompt": build_shot_prompt( + group, index=index, total=total, seconds=seconds, + subject_name=subject_name, has_triview=has_triview, replace_mode=replace_mode, + ), + }) + return plan + + +def build_shot_prompt( + shots, *, index: int, total: int, seconds: int, setting: str = "", + subject_name: str, has_triview: bool = False, replace_mode: str = "product", +) -> str: + """2.0 短指令 + 这一镜的提炼原文。@参考视频 才是切镜来源,不要扩写。""" + from .services import enforce_no_embedded_captions + + group = shots if isinstance(shots, list) else [shots] + subject = (subject_name or "").strip() or ("目标角色" if replace_mode == "character" else "目标商品") + is_character = replace_mode == "character" + + if len(group) == 1: + clock = _shot_clock(group[0]) + span = f",对应原片 {clock}" if clock else "" + scope = f"只生成下面这一镜(第 {index}/{total} 镜),时长 {seconds} 秒{span}。不要拍前后镜头,不要额外切镜。" + shot_body = _verbatim_shot_block(group[0]) + else: + scope = ( + f"按顺序只拍下面 {len(group)} 个连续切点(第 {index}/{total} 段),共 {seconds} 秒。" + "只在这些切点切换,不要加镜。" + ) + blocks = [] + for offset, shot in enumerate(group, start=1): + clock = _shot_clock(shot) + head = f"切点 {offset}" + (f"(原片 {clock})" if clock else "") + block = _verbatim_shot_block(shot) + blocks.append(f"{head}\n{block}" if block else head) + shot_body = "\n".join(blocks) + + if is_character: + swap = ( + "将画面中需要替换的原人物完整替换为@目标角色。" + "保留@参考视频的商品、场景、镜头运动、剪辑节奏与口播。" + ) + else: + swap_bits = [ + "将画面中需要替换的原商品完整替换为@目标商品的外观。", + "保留@参考视频的人物、场景、镜头运动、剪辑节奏与口播氛围。", + ] + if has_triview: + swap_bits.append(PRODUCT_TRIVIEW_NOTE) + if subject: + swap_bits.append(f"口播里的旧商品名改说「{subject}」。") + swap = "".join(swap_bits) + + parts = [ + "使用@参考视频作为镜头、节奏与口播氛围基准。", + scope, + swap, + shot_body, + ] + return enforce_no_embedded_captions("\n".join(part for part in parts if part)) + + +def build_character_replace_prompt( + digest_text: str, + *, + source_seconds: float = 0, + output_seconds: int = 0, +) -> str: + """解析不出逐镜计划时的整片回落:2.0 短指令,切镜靠 @参考视频。""" + 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 = ( + PRODUCT_CONDENSE_NOTE.format(source=source, output=output) + 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)) + + +def compact_digest_for_video(digest_text: str) -> str: + """分镜稿 → 交给视频模型的镜头表。 + + 解析失败(模型没按 skill 的格式吐)就回落:只砍掉【拆解存疑】,原文照传 —— + 宁可啰嗦也不能把镜头信息弄丢。 + """ + header, shots = _parse_digest(digest_text) + if not shots: + body = (digest_text or "").strip() + head, sep, _tail = body.partition(DIGEST_TAIL_SECTION) + return (head if sep else body).strip() + return _render_shot_table(header, shots) def build_product_replace_prompt( @@ -159,31 +382,25 @@ def build_product_replace_prompt( source_seconds: float = 0, output_seconds: int = 0, ) -> str: - """分镜稿 + 商品替换要求 → 交给 Seedance 的完整提示词。 - - @目标商品 必须与 references 里第一张商品图的 label 对上,否则 build_content_items - 不会把它换成火山认的「图片N」指代。 - """ + """解析不出逐镜计划时的整片回落:2.0 短指令,切镜靠 @参考视频。""" from .services import enforce_no_embedded_captions - body = compact_digest_for_video(digest_text) - if not body: + if not (digest_text or "").strip(): raise ValueError("参考视频拆解结果为空,请重试") - subject = (subject_name or "").strip() or "目标商品" + subject = (subject_name or "").strip() source = int(round(float(source_seconds or 0))) output = int(output_seconds or 0) - # 差 3 秒以内不啰嗦,模型自己会收;差得多才明确要求压缩改编。 condense = ( PRODUCT_CONDENSE_NOTE.format(source=source, output=output) if source and output and source - output >= 3 else "" ) - tail = PRODUCT_DIGEST_TAIL.format( - subject=subject, - triview=PRODUCT_TRIVIEW_NOTE if has_triview else "", - condense=condense, - ) - return enforce_no_embedded_captions(f"{PRODUCT_DIGEST_HEAD}\n\n{body}\n\n{tail}") + extra = [] + if has_triview: + 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)) def is_video_replace_task(task) -> bool: @@ -204,6 +421,16 @@ def serialize_video_replace_task(task, *, include_deleted_assets: bool = False) created = task.status == AITask.Status.CREATED digesting = created and bool(payload.get("digest_pending")) reviewing = created and bool(payload.get("review_pending")) and not digesting + plan = list(payload.get("shot_plan") or []) + shot_total = int(payload.get("shot_total") or len(plan) or 0) + shot_index = 0 + if plan: + active = next((item for item in plan if item.get("status") in {"submitted", "polling"}), None) + if active is None: + done = sum(1 for item in plan if item.get("status") == "succeeded") + shot_index = done + else: + shot_index = int(active.get("index") or 0) data.update({ "feature": FEATURE, "replace_mode": replace_mode, @@ -212,12 +439,13 @@ def serialize_video_replace_task(task, *, include_deleted_assets: bool = False) "product_id": payload.get("product_id") or "", "model_id": payload.get("model_id") or "", "review_stage": "reviewing" if reviewing else "", - # 商品复刻专有:参考视频拆出来的分镜稿(给用户看,也便于排查出片跑偏) "digest_stage": "digesting" if digesting else "", "digest_text": str(payload.get("digest_text") or ""), - "digest_shots": payload.get("digest_shots") or 0, + "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, + "shot_index": shot_index, + "shot_total": shot_total, }) return data @@ -290,65 +518,48 @@ def submit_video_replace(*, team, user, params: dict): "feature": FEATURE, } - if replace_mode == "product": - # 商品复刻:参考视频只喂给提炼模型,不进火山 references(所以参考视频本身不再送火山审核)。 - # 两个「一定会失败」的前提在这里就查掉,别让用户白等一轮提炼才看到报错: - from .video_digest import resolve_digest_model_config + # 分镜仍用「提炼提示词」同一套拆。参考视频先不进审核列表(原片不是要登记的人物素材), + # 出片时再带上 @参考视频 锁切镜 —— 这是 2.0 效果好的原因,2.5 同样需要。 + from .video_digest import resolve_digest_model_config - if resolve_digest_model_config() is None: - raise ValueError("视频提炼模型未配置,请联系管理员") - # 商品图仍要过审才能当生成参考。提前送审 → 审核和提炼并行跑,明确不过审的直接 400。 - review_state = _ensure_replace_refs_reviewed(team, image_refs) - if review_state == "failed": - raise ValueError(REVIEW_FAILED) - image_refs = _refresh_replace_refs(team, image_refs) - # 先秒回一个 CREATED 任务,拆解这种慢活(Gemini 半分钟起)交给 worker。 - extra.update({ - "digest_source_asset_id": str(video.id), - "digest_source_name": video.name or "参考视频", - "digest_source_duration": round(video_seconds, 2), - # 参考视频虽然不进 references,历史卡的「原视频」和「重新生成」仍要拿到它, - # 这里存一份引用快照,免得序列化历史时每条再查一次库。 - "digest_source_ref": _owned_ref(video, kind="video", role="reference_video", label="参考视频"), - }) - return _create_reviewing_task( - team=team, - user=user, - params={ - **base_params, - "prompt": DIGEST_PENDING_PROMPT, - "references": image_refs, - "extra_payload": extra, - }, - digest_pending=True, - ) - - references = [ - _owned_ref(video, kind="video", role="reference_video", label="参考视频"), - *image_refs, - ] - review_state = _ensure_replace_refs_reviewed(team, references) + if resolve_digest_model_config() is None: + raise ValueError("视频提炼模型未配置,请联系管理员") + # 商品图/角色图仍要过审才能当生成参考。提前送审 → 审核和提炼并行跑,明确不过审的直接 400。 + review_state = _ensure_replace_refs_reviewed(team, image_refs) if review_state == "failed": raise ValueError(REVIEW_FAILED) - references = _refresh_replace_refs(team, references) - extra["review_pending"] = review_state != "ready" - submit_params = { - **base_params, - "prompt": CHARACTER_PROMPT, - "references": references, - "extra_payload": extra, - } - if review_state == "ready": - _assert_replace_refs_ready(references) - return submit_free_video(team=team, user=user, params=submit_params) - return _create_reviewing_task(team=team, user=user, params=submit_params) + image_refs = _refresh_replace_refs(team, image_refs) + extra.update({ + "digest_source_asset_id": str(video.id), + "digest_source_name": video.name or "参考视频", + "digest_source_duration": round(video_seconds, 2), + # 审核只盯商品图/角色图。原片快照留给出片时锁切镜,也给历史卡「原视频」用。 + "digest_source_ref": _owned_ref(video, kind="video", role="reference_video", label="参考视频"), + "review_pending": review_state != "ready", + }) + return _create_reviewing_task( + team=team, + user=user, + params={ + **base_params, + "prompt": DIGEST_PENDING_PROMPT, + "references": image_refs, + "extra_payload": extra, + }, + digest_pending=True, + ) 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) @@ -383,13 +594,27 @@ def advance_video_replace(task): locked.request_payload = next_payload locked.save(update_fields=["request_payload", "updated_at"]) task = locked + return start_replace_generation(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"]) return start_pending_free_video(task) def run_replace_digest(task) -> None: - """Worker:商品复刻第一道工序——参考视频 → 分镜稿 → 完整提示词 → 走审核提交火山。 + """Worker:参考视频 → 分镜稿 → 逐镜计划 → 走审核提交火山。 - 这一步失败 = 整条复刻失败。此时还没预留视频积分(CREATED 阶段不预留),不扣费。 + 商品和角色共用。这一步失败 = 整条复刻失败。此时还没预留视频积分,不扣费。 """ from apps.ai.video_digest import VideoDigestError, digest_asset_video @@ -407,18 +632,31 @@ def run_replace_digest(task) -> None: _fail_reviewing_task(task, "参考视频已失效,请重新上传", error_code="asset_unavailable") return + replace_mode = str(payload.get("replace_mode") or "product") + subject = str(payload.get("subject_name") or "") + has_triview = any( + (ref or {}).get("label") == TRIVIEW_LABEL for ref in (payload.get("references") or []) + ) + source_seconds = float(payload.get("digest_source_duration") or 0) + output_seconds = int(payload.get("duration") or 0) + try: digest, meta = digest_asset_video(asset=asset, task=task) - has_triview = any( - (ref or {}).get("label") == TRIVIEW_LABEL for ref in (payload.get("references") or []) - ) - prompt = build_product_replace_prompt( - digest, - str(payload.get("subject_name") or ""), - has_triview=has_triview, - source_seconds=float(payload.get("digest_source_duration") or 0), - output_seconds=int(payload.get("duration") or 0), + 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": + prompt = build_character_replace_prompt( + digest, source_seconds=source_seconds, output_seconds=output_seconds, + ) + else: + prompt = build_product_replace_prompt( + digest, subject, has_triview=has_triview, + source_seconds=source_seconds, output_seconds=output_seconds, + ) except (VideoDigestError, ValueError) as exc: _fail_reviewing_task(task, str(exc), error_code="processing_failed") return @@ -436,11 +674,26 @@ 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) locked.request_payload = next_payload locked.save(update_fields=["request_payload", "updated_at"]) task = locked - # 拆完就地推进一次:商品图多半早已过审,能直接提交火山,省掉一轮 8s 轮询。 + # 拆完就地推进一次:参考图多半早已过审,能直接提交火山,省掉一轮 8s 轮询。 try: task = advance_video_replace(task) except Exception: # noqa: BLE001 — 推进失败交给轮询重试,任务还在 CREATED @@ -450,6 +703,496 @@ def run_replace_digest(task) -> None: _enqueue_replace_review_poll(task) +def start_pending_replace_shots(task): + """CREATED → 按全部镜头总时长预留积分 → 提交第一镜。""" + from apps.billing.services.ledger import reserve_credit + + from .free_video import ( + _dispatch_free_video_provider, + _fail_pending_free_video, + build_content_items, + model_duration_range, + ) + + if task.status != AITask.Status.CREATED: + return task + payload = task.request_payload or {} + plan = list(payload.get("shot_plan") or []) + if not plan: + return start_pending_free_video(task) + first = plan[0] + prompt = str(first.get("prompt") or payload.get("prompt") or "").strip() + mode = str(payload.get("mode") or "universal") + aspect_ratio = str(payload.get("aspect_ratio") or "9:16") + resolution = str(payload.get("resolution") or "720p") + generate_audio = bool(payload.get("generate_audio", True)) + search_mode = str(payload.get("search_mode") or "off") + feature = str(payload.get("feature") or FEATURE) + try: + seed = int(payload.get("seed") if payload.get("seed") is not None else -1) + except (TypeError, ValueError): + seed = -1 + billed_duration = sum(int(item.get("seconds") or 0) for item in plan) or int(payload.get("duration") or 5) + references = _seedance_references(payload) + try: + built = build_content_items( + team=task.team, prompt=prompt, mode=mode, references=references, + max_ref_seconds=model_duration_range(task.model_config)[1], + ) + except ValueError as exc: + message = str(exc) + if "正在审核" in message or "已提交审核" in message or "尚未完成合规审核" in message: + return task + return _fail_pending_free_video(task, message) + + tokens, quote = quote_video_estimate( + task.model_config, + aspect_ratio=aspect_ratio, + resolution=resolution, + duration=billed_duration, + references=built["snapshots"], + team=task.team, + ) + reserve_amount = video_reserve_amount(quote.points) + + with transaction.atomic(): + locked = ( + AITask.objects.select_for_update() + .select_related("model_config", "model_config__provider", "team", "created_by") + .get(id=task.id) + ) + if locked.status != AITask.Status.CREATED: + return locked + try: + reserve_credit(team=locked.team, user=locked.created_by, task=locked, amount=reserve_amount) + except ValueError as exc: + message = "团队余额不足,请充值后重试" if "insufficient credit" in str(exc) else str(exc) + locked.status = AITask.Status.FAILED + locked.error_code = "user_credit_insufficient" if "余额不足" in message else "invalid_input" + locked.error_message = message[:2000] + locked.completed_at = timezone.now() + locked.save(update_fields=["status", "error_code", "error_message", "completed_at", "updated_at"]) + return locked + next_payload = dict(locked.request_payload or {}) + plan = list(next_payload.get("shot_plan") or plan) + plan[0] = {**plan[0], "status": "submitted"} + next_payload["shot_plan"] = plan + next_payload["prompt"] = prompt + next_payload["api_prompt"] = built["api_prompt"] + next_payload["references"] = built["snapshots"] + next_payload["estimated_tokens"] = tokens + next_payload["review_pending"] = False + next_payload["duration"] = billed_duration + locked.request_payload = next_payload + locked.estimated_cost = quote.points + locked.status = AITask.Status.RESERVED + locked.save(update_fields=["request_payload", "estimated_cost", "status", "updated_at"]) + + dispatched = _dispatch_free_video_provider( + task=locked, + built=built, + model_config=locked.model_config, + aspect_ratio=aspect_ratio, + duration=int(first.get("seconds") or MIN_SHOT_SECONDS), + resolution=resolution, + generate_audio=generate_audio, + seed=seed, + search_mode=search_mode, + feature=feature, + mode=mode, + poll_countdown=30, + ) + return _stamp_current_shot_id(dispatched) + + +def advance_replace_shots(task): + """轮询当前这一镜:还在跑就等;失败整单失败;成功则提交下一镜,全部完成再拼接。""" + from .free_video import ( + _dispatch_free_video_provider, + build_content_items, + finalize_free_video, + model_duration_range, + ) + from .generation_errors import classify_generation_error + from .routing_policy import load_model_routing_policy + from .services import get_video_provider + + payload = dict(task.request_payload or {}) + plan = list(payload.get("shot_plan") or []) + if not plan: + return finalize_free_video(task=task) + if task.status not in (AITask.Status.RESERVED, AITask.Status.SUBMITTED, AITask.Status.POLLING): + return task + + current = _active_shot(plan) + if current is None: + if plan and all(item.get("status") == "succeeded" and item.get("media") for item in plan): + return complete_replace_shots(task) + return task + + if not current.get("provider_task_id"): + if task.provider_task_id and current.get("status") in {"submitted", "polling", "queued"}: + current = {**current, "provider_task_id": task.provider_task_id, "status": "submitted"} + _write_shot(task, current) + plan = _read_shot_plan(task) + current = _shot_by_index(plan, current["index"]) or current + else: + return _dispatch_next_replace_shot(task, current) + + video_policy = load_model_routing_policy().video + submit_attempt = ( + task.model_attempts.filter(status="succeeded", operation="video_generate") + .select_related("model_config__provider") + .order_by("-sequence") + .first() + ) + actual_model = submit_attempt.model_config if submit_attempt and submit_attempt.model_config else task.model_config + provider = get_video_provider(actual_model) + response = provider.poll_video_task( + endpoint=actual_model.endpoint, + provider_task_id=current["provider_task_id"], + timeout=video_policy.poll_request_timeout, + ) + remote_status = str(response.get("status") or "") + + if remote_status in {"queued", "running", "processing", "submitted"}: + if task.status != AITask.Status.POLLING or current.get("status") != "polling": + current = {**current, "status": "polling"} + task = _write_shot(task, current) + if task.status != AITask.Status.POLLING: + task.status = AITask.Status.POLLING + task.save(update_fields=["status", "updated_at"]) + return task + + if remote_status in {"failed", "expired", "cancelled"}: + err = response.get("error") or {} + code = str(err.get("code") or "") + raw_message = str(err.get("message") or "video generation failed") + public_error = classify_generation_error( + RuntimeError(raw_message), + operation="video_generate", + provider_code=code, + reference_id=str(task.id), + ) + return _fail_replace_generation( + task, raw_message, error_code=code[:64] or "CreateTaskError", hint=public_error.fallback_message, + ) + + try: + media = provider.extract_first_media_url(response) + except Exception as exc: # noqa: BLE001 + return _fail_replace_generation(task, str(exc), error_code="PostprocessError") + usage = response.get("usage") or {} + try: + tokens = int(usage.get("total_tokens") or 0) + except (TypeError, ValueError): + tokens = 0 + current = {**current, "status": "succeeded", "media": media, "tokens": tokens} + task = _write_shot(task, current) + plan = _read_shot_plan(task) + nxt = next((item for item in plan if item.get("status") in {"queued", "", None}), None) + if nxt is None: + if len(plan) == 1: + return finalize_free_video(task=task) + return complete_replace_shots(task) + return _dispatch_next_replace_shot(task, nxt) + + +def _dispatch_next_replace_shot(task, shot: dict): + from .free_video import _dispatch_free_video_provider, build_content_items, model_duration_range + + payload = dict(task.request_payload or {}) + prompt = str(shot.get("prompt") or "").strip() + if not prompt: + return _fail_replace_generation(task, "分镜提示词缺失,请重试", error_code="processing_failed") + try: + built = build_content_items( + team=task.team, prompt=prompt, mode=str(payload.get("mode") or "universal"), + references=_seedance_references(payload), + max_ref_seconds=model_duration_range(task.model_config)[1], + ) + except ValueError as exc: + return _fail_replace_generation(task, str(exc), error_code="invalid_input") + + with transaction.atomic(): + locked = AITask.objects.select_for_update().select_related( + "model_config", "model_config__provider", "team", "created_by", + ).get(id=task.id) + if locked.status not in (AITask.Status.RESERVED, AITask.Status.SUBMITTED, AITask.Status.POLLING): + return locked + plan = list((locked.request_payload or {}).get("shot_plan") or []) + target = _shot_by_index(plan, shot["index"]) + if target is None or target.get("status") not in {"queued", "", None, "submitted"}: + return locked + if target.get("provider_task_id") and target.get("status") in {"submitted", "polling"}: + return locked + next_payload = dict(locked.request_payload or {}) + next_payload["prompt"] = prompt + next_payload["api_prompt"] = built["api_prompt"] + plan = [ + {**item, "status": "submitted"} if item.get("index") == shot["index"] else item + for item in plan + ] + next_payload["shot_plan"] = plan + locked.request_payload = next_payload + locked.save(update_fields=["request_payload", "updated_at"]) + + try: + seed = int(payload.get("seed") if payload.get("seed") is not None else -1) + except (TypeError, ValueError): + seed = -1 + dispatched = _dispatch_free_video_provider( + task=locked, + built=built, + model_config=locked.model_config, + aspect_ratio=str(payload.get("aspect_ratio") or "9:16"), + duration=int(shot.get("seconds") or MIN_SHOT_SECONDS), + resolution=str(payload.get("resolution") or "720p"), + generate_audio=bool(payload.get("generate_audio", True)), + seed=seed, + search_mode=str(payload.get("search_mode") or "off"), + feature=str(payload.get("feature") or FEATURE), + mode=str(payload.get("mode") or "universal"), + poll_countdown=30, + ) + return _stamp_current_shot_id(dispatched) + + +def complete_replace_shots(task): + """全部镜头出完:下载 → ffmpeg 拼接 → 转存 TOS → 按 tokens 合计结算。""" + from decimal import Decimal + + from apps.billing.pricing import quote_video_actual + from apps.billing.services.ledger import charge_reserved_credit, release_credit + + from .free_video import _notify_failure, _store_free_video_media + from .generation_errors import classify_generation_error + + with transaction.atomic(): + locked = AITask.objects.select_for_update().get(id=task.id) + if locked.status not in (AITask.Status.SUBMITTED, AITask.Status.POLLING): + return locked + locked.status = AITask.Status.POSTPROCESSING + locked.save(update_fields=["status", "updated_at"]) + + payload = dict(locked.request_payload or {}) + plan = list(payload.get("shot_plan") or []) + try: + if len(plan) == 1 and plan[0].get("media"): + _store_free_video_media(task=locked, media=plan[0]["media"]) + else: + video_bytes = _concat_shot_media([item.get("media") or "" for item in plan]) + _store_free_video_media(task=locked, video_bytes=video_bytes) + total_tokens = 0 + for item in plan: + try: + total_tokens += int(item.get("tokens") or 0) + except (TypeError, ValueError): + pass + resolution = payload.get("resolution") or "720p" + if total_tokens > 0: + settle = quote_video_actual( + locked.model_config, tokens=total_tokens, with_video_ref=True, + resolution=resolution, multiplier=Decimal(str(payload.get("price_multiplier") or "1")), + ) + actual, base_cost = settle.points, settle.base_cost_yuan + payload["actual_tokens"] = total_tokens + if settle.meta.get("rate"): + payload["points_per_yuan_snapshot"] = settle.meta["rate"] + else: + actual, base_cost = locked.estimated_cost, locked.base_cost + with transaction.atomic(): + locked = AITask.objects.select_for_update().get(id=locked.id) + if locked.status != AITask.Status.POSTPROCESSING: + return locked + reservation = locked.credit_reservation + if actual > reservation.amount: + logger.warning( + "video replace task %s actual cost %s exceeds reserved %s, clamped", + locked.id, actual, reservation.amount, + ) + actual = reservation.amount + locked.status = AITask.Status.SUCCEEDED + locked.actual_cost = actual + locked.base_cost = base_cost + locked.request_payload = payload + locked.completed_at = timezone.now() + locked.save( + update_fields=["status", "actual_cost", "base_cost", "request_payload", "completed_at", "updated_at"] + ) + charge_reserved_credit(reservation=reservation, actual_amount=actual) + return locked + except Exception as exc: # noqa: BLE001 + logger.exception("video replace concat failed for task %s", locked.id) + public_error = classify_generation_error( + exc, operation="video_generate", internal_kind="processing_failed", + reference_id=str(locked.id), + ) + with transaction.atomic(): + locked = AITask.objects.select_for_update().get(id=locked.id) + if locked.status != AITask.Status.POSTPROCESSING: + return locked + locked.status = AITask.Status.FAILED + locked.error_code = "PostprocessError" + locked.error_message = str(exc)[:2000] + locked.completed_at = timezone.now() + locked.save(update_fields=["status", "error_code", "error_message", "completed_at", "updated_at"]) + release_credit(reservation=locked.credit_reservation, reason=str(exc)[:200]) + _notify_failure(locked, raw=str(exc), hint=public_error.fallback_message) + return locked + + +def _concat_shot_media(urls: list[str]) -> bytes: + import subprocess + import tempfile + from pathlib import Path + + from .providers.volcano import VolcanoArkProvider + + clips = [url for url in urls if url] + if not clips: + raise ValueError("没有可拼接的镜头成片") + if len(clips) == 1: + fileobj, _ctype = VolcanoArkProvider.media_to_bytes(clips[0]) + return fileobj.getvalue() + + with tempfile.TemporaryDirectory(prefix="airshelf-replace-concat-") as tmp: + tmp_dir = Path(tmp) + paths = [] + for index, url in enumerate(clips): + fileobj, _ctype = VolcanoArkProvider.media_to_bytes(url) + path = tmp_dir / f"shot{index:02d}.mp4" + path.write_bytes(fileobj.getvalue()) + paths.append(path) + output = tmp_dir / "out.mp4" + inputs: list[str] = [] + for path in paths: + inputs.extend(["-i", str(path)]) + n = len(paths) + parts = [f"[{i}:v]fps=24,format=yuv420p,setsar=1[v{i}]" for i in range(n)] + audio_ok = [] + for path in paths: + probe = subprocess.run( + ["ffprobe", "-v", "error", "-select_streams", "a", + "-show_entries", "stream=codec_type", "-of", "csv=p=0", str(path)], + capture_output=True, text=True, timeout=30, + ) + audio_ok.append(bool((probe.stdout or "").strip())) + for i, path in enumerate(paths): + if audio_ok[i]: + parts.append(f"[{i}:a]aresample=44100,aformat=sample_fmts=fltp:channel_layouts=stereo[a{i}]") + else: + dur_proc = subprocess.run( + ["ffprobe", "-v", "error", "-show_entries", "format=duration", + "-of", "csv=p=0", str(path)], + capture_output=True, text=True, timeout=30, + ) + try: + dur = max(0.1, float((dur_proc.stdout or "4").strip() or "4")) + except ValueError: + dur = 4.0 + parts.append( + f"anullsrc=channel_layout=stereo:sample_rate=44100,atrim=0:{dur:.3f}," + f"asetpts=PTS-STARTPTS,aformat=sample_fmts=fltp:channel_layouts=stereo[a{i}]" + ) + concat_in = "".join(f"[v{i}][a{i}]" for i in range(n)) + parts.append(f"{concat_in}concat=n={n}:v=1:a=1[v][a]") + cmd = [ + "ffmpeg", "-y", *inputs, "-filter_complex", ";".join(parts), + "-map", "[v]", "-map", "[a]", + "-c:v", "libx264", "-pix_fmt", "yuv420p", "-preset", "veryfast", "-threads", "2", + "-c:a", "aac", "-b:a", "192k", "-movflags", "+faststart", str(output), + ] + proc = subprocess.run(cmd, capture_output=True, timeout=180) + if proc.returncode != 0 or not output.exists() or not output.stat().st_size: + err = (proc.stderr or b"").decode("utf-8", errors="replace")[-500:] + raise RuntimeError(f"镜头拼接失败:{err or 'ffmpeg 未产出文件'}") + return output.read_bytes() + + +def _stamp_current_shot_id(task): + """把刚提交火山拿到的 provider_task_id 写回当前这一镜,下一轮 poll 才找得到。""" + if task.status not in (AITask.Status.SUBMITTED, AITask.Status.POLLING) or not task.provider_task_id: + return task + payload = dict(task.request_payload or {}) + plan = list(payload.get("shot_plan") or []) + changed = False + for item in plan: + if item.get("status") in {"submitted", "polling"} and not item.get("provider_task_id"): + item["provider_task_id"] = task.provider_task_id + changed = True + break + if not changed: + return task + payload["shot_plan"] = plan + task.request_payload = payload + task.save(update_fields=["request_payload", "updated_at"]) + return task + + +def _active_shot(plan: list[dict]) -> dict | None: + for item in plan: + if item.get("status") in {"submitted", "polling"}: + return item + for item in plan: + if item.get("status") in {"queued", "", None}: + return item + return None + + +def _shot_by_index(plan: list[dict], index) -> dict | None: + try: + index = int(index) + except (TypeError, ValueError): + return None + return next((item for item in plan if int(item.get("index") or 0) == index), None) + + +def _read_shot_plan(task) -> list[dict]: + return list((task.request_payload or {}).get("shot_plan") or []) + + +def _write_shot(task, shot: dict): + with transaction.atomic(): + locked = AITask.objects.select_for_update().get(id=task.id) + payload = dict(locked.request_payload or {}) + plan = list(payload.get("shot_plan") or []) + plan = [ + shot if int(item.get("index") or 0) == int(shot.get("index") or 0) else item + for item in plan + ] + payload["shot_plan"] = plan + if shot.get("provider_task_id"): + locked.provider_task_id = shot["provider_task_id"] + locked.request_payload = payload + locked.save(update_fields=["request_payload", "provider_task_id", "updated_at"]) + return locked + + +def _fail_replace_generation(task, message: str, *, error_code: str = "", hint: str = ""): + from apps.billing.services.ledger import release_credit + + from .free_video import _fail_pending_free_video, _notify_failure + + if task.status in (AITask.Status.CREATED, AITask.Status.RESERVED): + return _fail_reviewing_task(task, message, error_code=error_code or "processing_failed") + public_hint = hint or message + with transaction.atomic(): + locked = AITask.objects.select_for_update().get(id=task.id) + if locked.status in (AITask.Status.SUCCEEDED, AITask.Status.FAILED, AITask.Status.CANCELLED): + return locked + locked.status = AITask.Status.FAILED + locked.error_code = (error_code or "CreateTaskError")[:64] + locked.error_message = message[:2000] + locked.completed_at = timezone.now() + locked.save(update_fields=["status", "error_code", "error_message", "completed_at", "updated_at"]) + reservation = getattr(locked, "credit_reservation", None) + if reservation is not None: + release_credit(reservation=reservation, reason=message[:200]) + _notify_failure(locked, raw=message, hint=public_hint) + return locked + + def _legacy_replace_mode(prompt: str) -> str: return "character" if prompt.startswith("[视频复刻·角色]") else "product" @@ -491,7 +1234,7 @@ def _enqueue_replace_digest(task): def _create_reviewing_task(*, team, user, params: dict, digest_pending: bool = False): """还不能提交火山时:只建 CREATED 任务,不预留积分。 - 两种情况共用:①角色复刻的素材还在审核 ②商品复刻的参考视频还没拆解。 + 商品/角色复刻都先拆解参考视频;参考图若还在审核,拆完再等绿盾。 """ model_name = str(params.get("model") or REPLACE_MODEL) aspect_ratio = str(params.get("aspect_ratio") or "9:16") @@ -628,7 +1371,7 @@ def _ensure_replace_refs_reviewed(team, references: list) -> str: pairs = _replace_ref_assets(team, references) if not pairs: - raise ValueError("请先上传参考视频") + raise ValueError("请先上传参考素材") states = [] for ref, asset in pairs: label = ref.get("label") or asset.name or "参考素材" @@ -644,7 +1387,12 @@ def _ensure_replace_refs_reviewed(team, references: list) -> str: ok = submit_asset_for_review(asset, force=True) asset.refresh_from_db(fields=["review_status", "review_remote_id", "review_error"]) if not ok and not asset.review_remote_id: - raise ValueError(f"「{label}」{REVIEW_SUBMIT_FAILED}") + # 带上火山给的原因(submit_asset_for_review 会写进 review_error), + # 否则用户只看到「提交审核失败」,不知道是图不合规、拉不到图还是服务抽风。 + asset.refresh_from_db(fields=["review_error"]) + reason = (asset.review_error or "").strip() + detail = f"{REVIEW_SUBMIT_FAILED}({reason})" if reason else REVIEW_SUBMIT_FAILED + raise ValueError(f"「{label}」{detail}") if asset.review_status == "processing" and asset.review_remote_id: poll_asset_review(asset) asset.refresh_from_db(fields=["review_status", "review_remote_id", "review_error"]) @@ -797,6 +1545,35 @@ 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 = [ + dict(item) + for item in (payload.get("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 _library_image_ref(asset: Asset, *, team, label: str) -> dict: from .services import _asset_preview_url, _seedance_ref_url diff --git a/core/backend/apps/ai/views.py b/core/backend/apps/ai/views.py index 4c23260..6b8b2f8 100644 --- a/core/backend/apps/ai/views.py +++ b/core/backend/apps/ai/views.py @@ -866,9 +866,8 @@ class VideoReplaceView(APIView): def post(self, request): require_worker() - # 商品复刻的拆解在 worker 里跑;worker 还是旧镜像的话消息会被静默丢弃 → 永久「提炼中」。 - if str((request.data or {}).get("replace_mode") or "") == "product": - require_worker_task("apps.ai.tasks.run_video_replace_digest_task") + # 复刻的拆解在 worker 里跑;worker 还是旧镜像的话消息会被静默丢弃 → 永久「提炼中」。 + require_worker_task("apps.ai.tasks.run_video_replace_digest_task") from .video_replace import ( VideoReplaceInProgress, serialize_video_replace_task, @@ -897,8 +896,14 @@ class VideoReplaceView(APIView): public_error = classify_generation_error( exc, operation="video_generate", internal_kind=internal_kind ) + # detail 用校验本身的原话:submit_video_replace 抛的 ValueError 全都是写给用户看的 + # 中文("这个商品还没有可用图片""参考视频不能超过 30 秒"…)。套成 invalid_input 那句 + # 万能文案("请检查描述、参数或素材格式后重试")等于把原因丢了,用户和排查都没法下手。 + # error 里仍带结构化 code/action,前端要按类型渲染照旧可用。 + payload = public_error.as_dict() + payload["fallback_message"] = message return Response( - {"detail": public_error.fallback_message, "error": public_error.as_dict()}, + {"detail": message, "error": payload}, status=status.HTTP_400_BAD_REQUEST, ) task = _free_video_task_queryset(team).get(id=task.id) diff --git a/core/backend/apps/assets/image_guard.py b/core/backend/apps/assets/image_guard.py new file mode 100644 index 0000000..7aba8e2 --- /dev/null +++ b/core/backend/apps/assets/image_guard.py @@ -0,0 +1,39 @@ +"""上传图片的尺寸闸。 + +火山素材审核要求图片宽高在 300–6000 px 之间,小图会被 +[InvalidParameter.WidthTooSmall] 拒掉。这个限制以前只在自由创作的素材上传口做, +商品图 / 项目上传等入口没有 —— 小图能一路存进商品库,直到生成视频送审那一刻才炸, +而且报错发生在离上传很远的地方,用户根本对不上号。统一在入库前挡住。 +""" +from __future__ import annotations + +from io import BytesIO + +MIN_EDGE = 300 +MAX_EDGE = 6000 + + +class ImageTooSmallError(ValueError): + """尺寸不合格。文案直接面向用户。""" + + +def probe_image_size(raw: bytes) -> tuple[int, int]: + """读图片宽高。读不出来抛 ImageTooSmallError(当成坏文件)。""" + from PIL import Image + + try: + with Image.open(BytesIO(raw)) as im: + return int(im.width), int(im.height) + except Exception as exc: # noqa: BLE001 + raise ImageTooSmallError("图片解析失败,请更换文件") from exc + + +def ensure_reviewable_image(raw: bytes, *, label: str = "图片") -> tuple[int, int]: + """校验上传图片的宽高,返回 (width, height)。不合格抛 ImageTooSmallError。""" + width, height = probe_image_size(raw) + if not (MIN_EDGE <= width <= MAX_EDGE and MIN_EDGE <= height <= MAX_EDGE): + raise ImageTooSmallError( + f"{label}尺寸需在 {MIN_EDGE}–{MAX_EDGE} 像素之间(当前 {width}×{height})," + "过小的图无法通过素材审核,请换一张更大的图" + ) + return width, height diff --git a/core/backend/apps/assets/review.py b/core/backend/apps/assets/review.py index 6a92d8c..e862509 100644 --- a/core/backend/apps/assets/review.py +++ b/core/backend/apps/assets/review.py @@ -95,6 +95,7 @@ def submit_asset_for_review(asset: Asset, *, force: bool = False) -> bool: if not remote_id: # 火山没回 Id:不要标 processing(否则 remote_id 为空、poll 永远早退、卡死黄),留空可重试 logger.warning("create_asset 返回空 id,asset %s 暂不送审(可重试)", asset.id) + _record_submit_error(asset, "审核服务未返回素材编号") return False asset.review_remote_id = remote_id asset.review_status = "processing" @@ -103,9 +104,42 @@ def submit_asset_for_review(asset: Asset, *, force: bool = False) -> bool: return True except Exception as exc: # noqa: BLE001 logger.warning("submit_asset_for_review failed for asset %s: %s", asset.id, exc) + # 只写日志等于把原因埋了:调用方(视频复刻等)只能报「素材提交审核失败」这种没信息量的话, + # 用户和排查都无从下手。原因落到 review_error,让上层能带出来。 + _record_submit_error(asset, str(exc)) return False +# 火山素材审核的常见拒因:原文是英文错误码,直接怼给用户看不懂也不知道怎么办。 +_SUBMIT_ERROR_HINTS = ( + ("WidthTooSmall", "图片太小(宽高需在 300–6000 像素之间),请换一张更大的图"), + ("HeightTooSmall", "图片太小(宽高需在 300–6000 像素之间),请换一张更大的图"), + ("WidthTooLarge", "图片太大(宽高需在 300–6000 像素之间),请压缩后重试"), + ("HeightTooLarge", "图片太大(宽高需在 300–6000 像素之间),请压缩后重试"), + ("SizeTooLarge", "图片文件太大,请压缩后重试"), + ("InvalidImageFormat", "图片格式不支持,请改用 JPG / PNG / WebP"), + ("DownloadFailed", "审核服务拉取不到这张图,请重新上传"), +) + + +def humanize_submit_error(reason: str) -> str: + """把火山的英文错误码换成能照着做的中文。认不出的原样返回。""" + text = reason or "" + for code, hint in _SUBMIT_ERROR_HINTS: + if code in text: + return hint + return text + + +def _record_submit_error(asset: Asset, reason: str) -> None: + """把送审失败原因写回资产。写失败不抛 —— 审计不能反过来搞挂主流程。""" + try: + asset.review_error = humanize_submit_error(reason)[:2000] + asset.save(update_fields=["review_error", "updated_at"]) + except Exception: # noqa: BLE001 + logger.warning("记录 asset %s 送审失败原因时出错", asset.id, exc_info=True) + + # 平台自己生成的资产,提示词与生成链路都在我们手里,视为免审;用户上传的必须真过一遍审核。 # 判据是 Asset.source 而不是「在不在某个库里」—— 按库免审等于把审核架空: # 用户上传一张图进资产库,再从自由创作引用出去,就绕过了整套人像审核。 diff --git a/core/backend/apps/assets/tests.py b/core/backend/apps/assets/tests.py index 321fd83..41d303b 100644 --- a/core/backend/apps/assets/tests.py +++ b/core/backend/apps/assets/tests.py @@ -819,6 +819,20 @@ class FacetsProductsTests(TestCase): data = self.client.get("/api/assets/facets/?tab=others").json() self.assertEqual(data["products"], []) + def test_facets_products_newest_first(self): + from datetime import timedelta + + from django.utils import timezone + from apps.products.models import Product + + Asset.objects.create( + team=self.team, name="上身图2", asset_type="image", source="ai_generated", + category=Asset.Category.MODEL_TRYON, metadata={"product_id": str(self.p2.id)}, + ) + Product.objects.filter(id=self.p1.id).update(created_at=timezone.now() - timedelta(days=1)) + data = self.client.get("/api/assets/facets/?tab=tryon").json() + self.assertEqual([p["id"] for p in data["products"]], [str(self.p2.id), str(self.p1.id)]) + class SubmitReviewTests(TestCase): """手动兜底:灰盾点击 → 提交审核;只对送审类放行,非送审类 400。""" @@ -927,3 +941,55 @@ class ReviewScopeTests(TestCase): self.assertEqual(out["statuses"][str(proc.id)], "active") sub.assert_called_once() polled.assert_called_once() + + +class ImageSizeGuardTests(TestCase): + """火山素材审核要求图片 300–6000 px。小图以前能一路存进商品库, + 直到生成视频送审那一刻才炸([InvalidParameter.WidthTooSmall]) —— 报错离上传很远, + 用户对不上号。改成入库前就挡住。""" + + def _png(self, width, height): + from io import BytesIO + + from PIL import Image + + buf = BytesIO() + Image.new("RGB", (width, height), "white").save(buf, format="PNG") + return buf.getvalue() + + def test_rejects_image_below_300px(self): + from apps.assets.image_guard import ImageTooSmallError, ensure_reviewable_image + + with self.assertRaises(ImageTooSmallError) as ctx: + ensure_reviewable_image(self._png(120, 800), label="商品图") + self.assertIn("商品图", str(ctx.exception)) + self.assertIn("300", str(ctx.exception)) + self.assertIn("120×800", str(ctx.exception)) # 把实际尺寸写给用户,免得反复试 + + def test_rejects_image_above_6000px(self): + from apps.assets.image_guard import ImageTooSmallError, ensure_reviewable_image + + with self.assertRaises(ImageTooSmallError): + ensure_reviewable_image(self._png(6400, 900)) + + def test_accepts_image_in_range_and_returns_size(self): + from apps.assets.image_guard import ensure_reviewable_image + + self.assertEqual(ensure_reviewable_image(self._png(800, 1200)), (800, 1200)) + + def test_broken_file_is_rejected(self): + from apps.assets.image_guard import ImageTooSmallError, ensure_reviewable_image + + with self.assertRaises(ImageTooSmallError): + ensure_reviewable_image(b"not-an-image") + + def test_volcano_error_codes_are_humanized(self): + from apps.assets.review import humanize_submit_error + + self.assertIn( + "图片太小", + humanize_submit_error("[InvalidParameter.WidthTooSmall] Width must be between 300px and 6000px."), + ) + self.assertIn("格式不支持", humanize_submit_error("[InvalidParameter.InvalidImageFormat] bad")) + # 认不出的原样带出,不要吞掉 + self.assertEqual(humanize_submit_error("Some brand new error"), "Some brand new error") diff --git a/core/backend/apps/assets/views.py b/core/backend/apps/assets/views.py index e2966f4..a598d6f 100644 --- a/core/backend/apps/assets/views.py +++ b/core/backend/apps/assets/views.py @@ -413,11 +413,13 @@ class AssetViewSet(TeamScopedViewSetMixin, ModelViewSet): product_ids.update(str(x) for x in pid3 if x) if not product_ids: return [] - # 团队隔离(即便某 product_id 越权也过滤掉);只取 id/title - rows = Product.objects.filter(team=self.get_team(), id__in=product_ids).values("id", "title") - products = [{"id": str(r["id"]), "title": r["title"]} for r in rows] - products.sort(key=lambda x: x["title"] or "") - return products + # 团队隔离(即便某 product_id 越权也过滤掉);只取 id/title,最新添加的在前 + rows = ( + Product.objects.filter(team=self.get_team(), id__in=product_ids) + .order_by("-created_at") + .values("id", "title") + ) + return [{"id": str(r["id"]), "title": r["title"]} for r in rows] @action(detail=True, methods=["get"], url_path="raw") def raw(self, request, pk=None): diff --git a/core/backend/apps/products/migrations/0005_product_ordering_created_at.py b/core/backend/apps/products/migrations/0005_product_ordering_created_at.py new file mode 100644 index 0000000..a5b122e --- /dev/null +++ b/core/backend/apps/products/migrations/0005_product_ordering_created_at.py @@ -0,0 +1,15 @@ +from django.db import migrations + + +class Migration(migrations.Migration): + + dependencies = [ + ("products", "0004_product_business_type"), + ] + + operations = [ + migrations.AlterModelOptions( + name="product", + options={"ordering": ["-created_at"]}, + ), + ] diff --git a/core/backend/apps/products/models.py b/core/backend/apps/products/models.py index 7d1610e..f057dbf 100644 --- a/core/backend/apps/products/models.py +++ b/core/backend/apps/products/models.py @@ -38,6 +38,7 @@ class Product(TeamOwnedModel): ) class Meta: + ordering = ["-created_at"] indexes = [ models.Index(fields=["team", "status"]), models.Index(fields=["team", "status", "purged_at"]), diff --git a/core/backend/apps/products/tests.py b/core/backend/apps/products/tests.py index 8e54eaf..eb45a68 100644 --- a/core/backend/apps/products/tests.py +++ b/core/backend/apps/products/tests.py @@ -1,5 +1,8 @@ -from rest_framework.test import APIClient +from datetime import timedelta + from django.test import TestCase +from django.utils import timezone +from rest_framework.test import APIClient from apps.accounts.models import Team, TeamMember, User from apps.assets.models import Asset, AssetFile @@ -242,6 +245,23 @@ class ProductBusinessTypeTests(TestCase): self.assertIn("火锅套餐", local) self.assertNotIn("面膜", local) + def test_list_newest_created_first(self): + older = Product.objects.create(team=self.team, created_by=self.user, title="旧商品") + newer = Product.objects.create(team=self.team, created_by=self.user, title="新商品") + Product.objects.filter(id=older.id).update(created_at=timezone.now() - timedelta(days=1)) + titles = [p["title"] for p in self.client.get("/api/products/").json()["results"]] + self.assertEqual(titles[:2], ["新商品", "旧商品"]) + self.assertEqual(titles[0], newer.title) + + def test_trash_newest_created_first(self): + older = Product.objects.create(team=self.team, created_by=self.user, title="旧删") + newer = Product.objects.create(team=self.team, created_by=self.user, title="新删") + Product.objects.filter(id=older.id).update(created_at=timezone.now() - timedelta(days=1)) + self.client.delete(f"/api/products/{older.id}/") + self.client.delete(f"/api/products/{newer.id}/") + titles = [p["title"] for p in self.client.get("/api/products/trash/").json()["results"]] + self.assertEqual(titles[:2], ["新删", "旧删"]) + class ProductTriviewFieldTests(TestCase): diff --git a/core/backend/apps/products/views.py b/core/backend/apps/products/views.py index 098f927..2873056 100644 --- a/core/backend/apps/products/views.py +++ b/core/backend/apps/products/views.py @@ -1,3 +1,4 @@ +from io import BytesIO from pathlib import Path import uuid @@ -27,6 +28,7 @@ class ProductViewSet(TeamScopedViewSetMixin, ModelViewSet): serializer_class = ProductSerializer search_fields = ["title", "brand", "category"] ordering_fields = ["created_at", "updated_at", "title"] + ordering = ["-created_at"] def get_queryset(self): # 软删除即「进垃圾桶」(status=archived):正常列表/详情只看 active; @@ -39,7 +41,7 @@ class ProductViewSet(TeamScopedViewSetMixin, ModelViewSet): business_type = self.request.query_params.get("business_type") if business_type in dict(Product.BusinessType.choices): qs = qs.filter(business_type=business_type) - return qs + return qs.order_by("-created_at") def perform_destroy(self, instance): # 删除 = 软删进垃圾桶(可恢复),不真删数据 @@ -174,11 +176,21 @@ class ProductViewSet(TeamScopedViewSetMixin, ModelViewSet): if upload is None: return Response({"detail": "no file"}, status=status.HTTP_400_BAD_REQUEST) + # 尺寸闸:小图能存进商品库,但送火山审核时会被 WidthTooSmall 拒 —— + # 那时报错离上传很远,用户对不上号。在入库前就挡住。 + from apps.assets.image_guard import ImageTooSmallError, ensure_reviewable_image + + raw = upload.read() + try: + width, height = ensure_reviewable_image(raw, label="商品图") + except ImageTooSmallError as exc: + return Response({"detail": str(exc)}, status=status.HTTP_400_BAD_REQUEST) + suffix = Path(upload.name).suffix.lower() or ".png" asset_id = uuid.uuid4() object_key = f"teams/{team.id}/products/{product.id}/{asset_id}{suffix}" stored = TosStorage().upload_fileobj( - fileobj=upload.file, + fileobj=BytesIO(raw), object_key=object_key, content_type=upload.content_type or "image/png", ) @@ -197,6 +209,8 @@ class ProductViewSet(TeamScopedViewSetMixin, ModelViewSet): bucket=stored.bucket, content_type=stored.content_type, size_bytes=stored.size_bytes, + width=width, + height=height, is_primary=True, ) next_order = product.images.count() diff --git a/core/backend/apps/projects/views.py b/core/backend/apps/projects/views.py index b09e449..a772a6f 100644 --- a/core/backend/apps/projects/views.py +++ b/core/backend/apps/projects/views.py @@ -1,4 +1,5 @@ import logging +from io import BytesIO from pathlib import Path import uuid @@ -115,8 +116,23 @@ def _store_uploaded_asset(*, team, user, upload, asset_type: str, category: str, suffix = Path(upload.name).suffix.lower() or fallback_suffix asset_id = uuid.uuid4() object_key = f"teams/{team.id}/uploads/{asset_id}{suffix}" + # 图片走尺寸闸:小于 300px 的图能存进来,但送火山审核会被 WidthTooSmall 拒 —— + # 那时报错离上传很远(要到生成视频那一步),用户根本对不上号。 + width = height = None + if asset_type == Asset.Type.IMAGE: + from apps.assets.image_guard import ImageTooSmallError, ensure_reviewable_image + + raw = upload.read() + try: + width, height = ensure_reviewable_image(raw, label=name or "图片") + except ImageTooSmallError as exc: + # 转成 DRF 校验错 → 400 + 原文;裸 ValueError 会变成 500 + raise ValidationError({"detail": str(exc)}) from exc + fileobj = BytesIO(raw) + else: + fileobj = upload.file stored = TosStorage().upload_fileobj( - fileobj=upload.file, + fileobj=fileobj, object_key=object_key, content_type=upload.content_type or "application/octet-stream", ) @@ -126,7 +142,8 @@ def _store_uploaded_asset(*, team, user, upload, asset_type: str, category: str, ) AssetFile.objects.create( asset=asset, object_key=stored.object_key, bucket=stored.bucket, - content_type=stored.content_type, size_bytes=stored.size_bytes, is_primary=True, + content_type=stored.content_type, size_bytes=stored.size_bytes, + width=width, height=height, is_primary=True, ) return asset diff --git a/core/frontend/src/library-page.css b/core/frontend/src/library-page.css index 2535fd0..2c5e697 100644 --- a/core/frontend/src/library-page.css +++ b/core/frontend/src/library-page.css @@ -12,7 +12,7 @@ max-width: none; box-sizing: border-box; margin: -24px -28px -60px; - padding: 52px clamp(40px, 3.2vw, 68px) 48px; + padding: 28px clamp(24px, 2.2vw, 40px) 36px; } @media (max-width: 1100px) { @@ -25,33 +25,32 @@ } .library-page .lib-head { - min-height: 76px; display: flex; align-items: flex-start; justify-content: space-between; - gap: 30px; - margin-bottom: 34px; + gap: 20px; + margin-bottom: 18px; } .library-page .lib-head h1 { - margin: 0 0 10px; - font-size: 32px; - line-height: 1.2; + margin: 0 0 4px; + font-size: 22px; + line-height: 1.25; font-weight: 600; letter-spacing: -.02em; color: var(--accent-black); } -.library-page .lib-head p { margin: 0; color: var(--lib-muted); font-size: 15px; } +.library-page .lib-head p { margin: 0; color: var(--lib-muted); font-size: 13px; } .library-page .lib-actions { display: flex; align-items: center; gap: 10px; flex-shrink: 0; } .library-page .lib-ghost { - min-width: 132px; - height: 46px; - padding: 0 18px; + min-width: 108px; + height: 36px; + padding: 0 14px; display: inline-flex; align-items: center; justify-content: center; gap: 9px; border: 1px solid rgba(28, 34, 43, 0.12); - border-radius: 11px; + border-radius: 8px; color: var(--accent-black); background: rgba(34, 42, 54, 0.05); font: inherit; @@ -59,17 +58,17 @@ } .library-page .lib-ghost:hover { background: rgba(34, 42, 54, 0.10); } .library-page .lib-ghost.is-on { color: #fff; border-color: var(--lib-black); background: var(--lib-black); } -.library-page .lib-ghost svg { width: 19px; height: 19px; } +.library-page .lib-ghost svg { width: 16px; height: 16px; } .library-page .lib-toolbar { - min-height: 66px; + min-height: 48px; display: flex; align-items: center; justify-content: space-between; - gap: 18px; - padding: 10px 14px; + gap: 12px; + padding: 8px 10px; border: 1px solid rgba(34, 42, 54, 0.09); - border-radius: 13px; + border-radius: 10px; background: var(--lib-wash); box-shadow: 0 7px 17px rgba(20, 27, 38, 0.07); } @@ -207,21 +206,21 @@ .library-page .lib-select-option:hover { background: #e8f2ff; } .library-page .lib-select-option.selected { color: var(--klein); background: #f3f7ff; font-weight: 600; } -.library-page .lib-section { margin: 32px 0 17px; } -.library-page .lib-section h2 { margin: 0; font-size: 21px; font-weight: 600; color: var(--accent-black); } +.library-page .lib-section { margin: 22px 0 12px; } +.library-page .lib-section h2 { margin: 0; font-size: 16px; font-weight: 600; color: var(--accent-black); } .library-page .lib-section p { margin: 5px 0 0; color: var(--lib-muted); font-size: 13px; } .library-page .lib-grid { display: grid; - grid-template-columns: repeat(4, minmax(0, 1fr)); - gap: 18px; + grid-template-columns: repeat(auto-fill, minmax(280px, 1fr)); + gap: 12px; } .library-page .lib-card { position: relative; min-width: 0; overflow: hidden; border: 1px solid var(--lib-line); - border-radius: 13px; + border-radius: 10px; background: rgba(34, 42, 54, 0.05); box-shadow: var(--lib-shadow); cursor: pointer; @@ -235,7 +234,7 @@ } .library-page .lib-thumb { position: relative; - aspect-ratio: 1 / 0.78; + aspect-ratio: 4 / 3; overflow: hidden; display: grid; place-items: center; @@ -253,10 +252,10 @@ display: block; object-fit: cover; } -.library-page .lib-meta { padding: 15px 16px 17px; } +.library-page .lib-meta { padding: 10px 12px 12px; } .library-page .lib-meta h3 { - margin: 0 0 6px; - font-size: 15px; + margin: 0 0 4px; + font-size: 13px; font-weight: 600; color: var(--accent-black); overflow: hidden; @@ -276,14 +275,14 @@ align-items: center; flex-wrap: wrap; gap: 6px; - margin-top: 13px; + margin-top: 8px; } .library-page .lib-tag { display: inline-flex; align-items: center; - height: 25px; - padding: 0 9px; - border-radius: 7px; + height: 22px; + padding: 0 7px; + border-radius: 6px; color: #4c5360; background: rgba(34, 42, 54, 0.08); font-size: 11px; @@ -430,15 +429,12 @@ .library-page .lib-sel-confirm:disabled { opacity: 0.4; cursor: not-allowed; } .library-page .list-pager { margin-top: 20px; } -@media (max-width: 1500px) { - .library-page .lib-grid { grid-template-columns: repeat(3, minmax(0, 1fr)); } -} @media (max-width: 1100px) { .library-page .lib-sel-bar { left: 50%; } .library-page .lib-toolbar { flex-wrap: wrap; } } @media (max-width: 860px) { - .library-page .lib-grid { grid-template-columns: repeat(2, minmax(0, 1fr)); } + .library-page .lib-grid { grid-template-columns: repeat(auto-fill, minmax(160px, 1fr)); } .library-page .lib-search { width: 100%; } } diff --git a/core/frontend/src/models-page.css b/core/frontend/src/models-page.css index 65f531f..3355616 100644 --- a/core/frontend/src/models-page.css +++ b/core/frontend/src/models-page.css @@ -11,7 +11,7 @@ max-width: none; box-sizing: border-box; margin: -24px -28px -60px; - padding: 52px clamp(40px, 3.2vw, 68px) 48px; + padding: 28px clamp(24px, 2.2vw, 40px) 36px; } @media (max-width: 1100px) { @@ -24,22 +24,21 @@ } .models-page .ml-head { - min-height: 76px; display: flex; align-items: flex-start; justify-content: space-between; - gap: 30px; - margin-bottom: 34px; + gap: 20px; + margin-bottom: 18px; } .models-page .ml-head h1 { - margin: 0 0 10px; - font-size: 32px; - line-height: 1.2; + margin: 0 0 4px; + font-size: 22px; + line-height: 1.25; font-weight: 600; letter-spacing: -.02em; color: var(--accent-black); } -.models-page .ml-head p { margin: 0; color: var(--ml-muted); font-size: 15px; } +.models-page .ml-head p { margin: 0; color: var(--ml-muted); font-size: 13px; } .models-page .ml-actions { display: flex; align-items: center; gap: 10px; flex-shrink: 0; } .models-page .ml-ghost, .models-page .ml-primary { @@ -52,73 +51,73 @@ cursor: pointer; } .models-page .ml-ghost { - min-width: 132px; - height: 46px; - padding: 0 18px; + min-width: 108px; + height: 36px; + padding: 0 14px; border: 1px solid rgba(28, 34, 43, 0.12); - border-radius: 11px; + border-radius: 8px; color: var(--accent-black); background: rgba(34, 42, 54, 0.05); } .models-page .ml-ghost:hover { background: rgba(34, 42, 54, 0.10); } .models-page .ml-ghost.is-on { color: #fff; border-color: var(--ml-black); background: var(--ml-black); } .models-page .ml-primary { - min-width: 158px; - height: 50px; - padding: 0 22px; - border-radius: 11px; + min-width: 124px; + height: 36px; + padding: 0 16px; + border-radius: 8px; color: #fff; background: var(--klein); - box-shadow: 0 9px 18px rgba(0, 47, 167, 0.18); + box-shadow: 0 6px 14px rgba(0, 47, 167, 0.18); } .models-page .ml-primary:hover { transform: translateY(-2px); background: var(--klein-hover); } .models-page .ml-primary:disabled { opacity: 0.38; transform: none; box-shadow: none; cursor: not-allowed; } .models-page .ml-ghost svg, -.models-page .ml-primary svg { width: 19px; height: 19px; } +.models-page .ml-primary svg { width: 16px; height: 16px; } .models-page .ml-toolbar { - min-height: 66px; + min-height: 48px; display: flex; align-items: center; justify-content: space-between; - gap: 18px; - padding: 10px 14px; + gap: 12px; + padding: 8px 10px; border: 1px solid rgba(34, 42, 54, 0.09); - border-radius: 13px; + border-radius: 10px; background: var(--ml-wash); box-shadow: 0 7px 17px rgba(20, 27, 38, 0.07); } .models-page .ml-seg { display: flex; align-items: center; gap: 8px; flex-wrap: wrap; } .models-page .ml-seg-btn { - height: 38px; - padding: 0 16px; + height: 32px; + padding: 0 12px; border: 0; - border-radius: 9px; + border-radius: 8px; color: #50555d; background: transparent; font: inherit; - font-size: 14px; + font-size: 13px; cursor: pointer; } .models-page .ml-seg-btn:hover { color: var(--accent-black); } .models-page .ml-seg-btn.active { color: #fff; background: var(--ml-black); } .models-page .ml-note { color: var(--ml-muted); font-size: 12px; } -.models-page .ml-section { margin: 32px 0 17px; } -.models-page .ml-section h2 { margin: 0; font-size: 21px; font-weight: 600; color: var(--accent-black); } +.models-page .ml-section { margin: 22px 0 12px; } +.models-page .ml-section h2 { margin: 0; font-size: 16px; font-weight: 600; color: var(--accent-black); } .models-page .ml-section p { margin: 5px 0 0; color: var(--ml-muted); font-size: 13px; } .models-page .ml-grid { display: grid; - grid-template-columns: repeat(4, minmax(0, 1fr)); - gap: 18px; + grid-template-columns: repeat(auto-fill, minmax(280px, 1fr)); + gap: 12px; } .models-page .ml-card { position: relative; min-width: 0; overflow: hidden; border: 1px solid var(--ml-line); - border-radius: 13px; + border-radius: 10px; background: rgba(34, 42, 54, 0.05); box-shadow: var(--ml-shadow); cursor: pointer; @@ -147,10 +146,10 @@ border: 0; border-radius: 0; } -.models-page .ml-meta { padding: 12px 13px 14px; } +.models-page .ml-meta { padding: 8px 10px 10px; } .models-page .ml-meta h3 { - margin: 0 0 6px; - font-size: 15px; + margin: 0 0 3px; + font-size: 13px; font-weight: 600; overflow: hidden; text-overflow: ellipsis; @@ -162,14 +161,14 @@ align-items: center; flex-wrap: wrap; gap: 6px; - margin-top: 13px; + margin-top: 8px; } .models-page .ml-tag { display: inline-flex; align-items: center; - height: 25px; - padding: 0 9px; - border-radius: 7px; + height: 20px; + padding: 0 6px; + border-radius: 6px; color: #4c5360; background: rgba(34, 42, 54, 0.08); font-size: 11px; @@ -274,14 +273,11 @@ .models-page .ml-sel-confirm { color: #fff; background: #002fa7; } .models-page .ml-sel-confirm:disabled { opacity: 0.4; cursor: not-allowed; } -@media (max-width: 1500px) { - .models-page .ml-grid { grid-template-columns: repeat(3, minmax(0, 1fr)); } -} @media (max-width: 1100px) { .models-page .ml-sel-bar { left: 50%; } } @media (max-width: 860px) { - .models-page .ml-grid { grid-template-columns: repeat(2, minmax(0, 1fr)); } + .models-page .ml-grid { grid-template-columns: repeat(auto-fill, minmax(148px, 1fr)); } } /* 模特详情弹窗:参考视频项目角色详情结构 */ diff --git a/core/frontend/src/products-page.css b/core/frontend/src/products-page.css index c2b3452..e688a8f 100644 --- a/core/frontend/src/products-page.css +++ b/core/frontend/src/products-page.css @@ -12,7 +12,7 @@ max-width: none; box-sizing: border-box; margin: -24px -28px -60px; - padding: 52px clamp(40px, 3.2vw, 68px) 48px; + padding: 28px clamp(24px, 2.2vw, 40px) 36px; .pl-inner { width: min(1560px, 100%); @@ -20,17 +20,16 @@ } .pl-head { - min-height: 76px; display: flex; align-items: flex-start; justify-content: space-between; - gap: 30px; - margin-bottom: 34px; + gap: 20px; + margin-bottom: 18px; } .pl-head h1 { - margin: 0 0 10px; - font-size: 32px; - line-height: 1.2; + margin: 0 0 4px; + font-size: 22px; + line-height: 1.25; font-weight: 600; letter-spacing: -.02em; color: var(--accent-black); @@ -38,7 +37,7 @@ .pl-head p { margin: 0; color: var(--pl-muted); - font-size: 15px; + font-size: 13px; } .pl-actions { display: flex; @@ -58,11 +57,11 @@ transition: transform 180ms ease, background-color 180ms ease, box-shadow 180ms ease; } .pl-ghost { - min-width: 132px; - height: 46px; - padding: 0 18px; + min-width: 108px; + height: 36px; + padding: 0 14px; border: 1px solid rgba(28, 34, 43, 0.12); - border-radius: 11px; + border-radius: 8px; color: var(--accent-black); background: rgba(34, 42, 54, 0.05); } @@ -73,36 +72,36 @@ background: var(--pl-black); } .pl-primary { - min-width: 158px; - height: 50px; - padding: 0 22px; - border-radius: 11px; + min-width: 124px; + height: 36px; + padding: 0 16px; + border-radius: 8px; color: #fff; background: var(--klein); - box-shadow: 0 9px 18px rgba(0, 47, 167, 0.18); + box-shadow: 0 6px 14px rgba(0, 47, 167, 0.18); } .pl-primary:hover { transform: translateY(-2px); background: var(--klein-hover); } .pl-ghost svg, - .pl-primary svg { width: 19px; height: 19px; flex: 0 0 auto; } + .pl-primary svg { width: 16px; height: 16px; flex: 0 0 auto; } .pl-toolbar { - min-height: 66px; + min-height: 48px; display: flex; align-items: center; justify-content: space-between; - gap: 18px; - padding: 10px 14px; + gap: 12px; + padding: 8px 10px; border: 1px solid rgba(34, 42, 54, 0.09); - border-radius: 13px; + border-radius: 10px; background: var(--pl-wash); box-shadow: 0 7px 17px rgba(20, 27, 38, 0.07); } .pl-search { - width: 250px; - height: 40px; + width: 220px; + height: 34px; display: flex; align-items: center; gap: 8px; @@ -140,7 +139,7 @@ .pl-select[data-key="cat"] { width: 168px; flex-basis: 168px; } .pl-select-trigger { width: 100%; - height: 40px; + height: 34px; display: flex; align-items: center; justify-content: space-between; @@ -225,7 +224,7 @@ .pl-select-option.selected .pl-cat-count { color: var(--klein); } .pl-clear { - height: 40px; + height: 34px; padding: 0 12px; border: 0; border-radius: 10px; @@ -243,8 +242,8 @@ gap: 8px; } .pl-view-btn { - width: 40px; - height: 40px; + width: 34px; + height: 34px; padding: 0; display: inline-flex; align-items: center; @@ -264,11 +263,11 @@ align-items: center; justify-content: space-between; gap: 22px; - margin: 32px 0 17px; + margin: 22px 0 12px; } .pl-section h2 { margin: 0; - font-size: 21px; + font-size: 16px; font-weight: 600; color: var(--accent-black); } @@ -280,8 +279,8 @@ .pl-grid { display: grid; - grid-template-columns: repeat(4, minmax(0, 1fr)); - gap: 18px; + grid-template-columns: repeat(auto-fill, minmax(280px, 1fr)); + gap: 12px; } .pl-card { position: relative; @@ -290,7 +289,7 @@ display: flex; flex-direction: column; border: 1px solid var(--pl-line); - border-radius: 13px; + border-radius: 10px; background: rgba(34, 42, 54, 0.05); box-shadow: var(--pl-shadow); cursor: pointer; @@ -308,7 +307,7 @@ .pl-thumb { position: relative; - aspect-ratio: 1 / 0.78; + aspect-ratio: 4 / 3; overflow: hidden; background: #e6e7ea; border: 0; @@ -332,10 +331,10 @@ } .pl-thumb .ph-frame { display: none; } - .pl-meta { padding: 15px 16px 17px; } + .pl-meta { padding: 10px 12px 12px; } .pl-meta h3 { - margin: 0 0 6px; - font-size: 15px; + margin: 0 0 4px; + font-size: 13px; font-weight: 600; color: var(--accent-black); overflow: hidden; @@ -355,15 +354,15 @@ align-items: center; justify-content: space-between; gap: 10px; - margin-top: 13px; + margin-top: 8px; } .pl-tag { display: inline-flex; align-items: center; - height: 25px; - padding: 0 9px; + height: 22px; + padding: 0 7px; border: 0; - border-radius: 7px; + border-radius: 6px; color: #4c5360; background: rgba(34, 42, 54, 0.08); font: inherit; @@ -406,18 +405,18 @@ .pl-grid.list { grid-template-columns: 1fr; } .pl-grid.list .pl-card { display: grid; - grid-template-columns: 190px minmax(0, 1fr); + grid-template-columns: 148px minmax(0, 1fr); align-items: stretch; } .pl-grid.list .pl-thumb { - width: 190px; - height: 130px; + width: 148px; + height: 100px; aspect-ratio: auto; } .pl-grid.list .pl-meta { display: flex; min-width: 0; - padding: 18px 22px; + padding: 12px 16px; flex-direction: column; justify-content: center; } @@ -462,14 +461,11 @@ .list-pager { margin-top: 20px; } } -@media (max-width: 1500px) { - .products-page .pl-grid { grid-template-columns: repeat(3, minmax(0, 1fr)); } -} @media (max-width: 1100px) { .products-page { margin: -28px -24px -48px; } } @media (max-width: 860px) { - .products-page .pl-grid { grid-template-columns: repeat(2, minmax(0, 1fr)); } + .products-page .pl-grid { grid-template-columns: repeat(auto-fill, minmax(160px, 1fr)); } .products-page .pl-toolbar { flex-wrap: wrap; } .products-page .pl-search { width: 100%; } } diff --git a/core/frontend/src/routes/admin/admin-reviews.tsx b/core/frontend/src/routes/admin/admin-reviews.tsx index e708ada..3949c51 100644 --- a/core/frontend/src/routes/admin/admin-reviews.tsx +++ b/core/frontend/src/routes/admin/admin-reviews.tsx @@ -32,6 +32,7 @@ export function AdminReviewsPage({ notify }: { notify: Notify }) { const [loading, setLoading] = useState(true); const [page, setPage] = useState(1); const [tab, setTab] = useState(""); + const [selected, setSelected] = useState>(new Set()); const [busy, setBusy] = useState(false); const [preview, setPreview] = useState<{ src: string; name: string } | null>(null); @@ -42,6 +43,11 @@ export function AdminReviewsPage({ notify }: { notify: Notify }) { if (res.results.length === 0 && res.count > 0 && page > 1) { setPage((p) => Math.max(1, p - 1)); return; } setAssets(res.results); setCount(res.count); + if (!silent) setSelected(new Set()); + else { + const ids = new Set(res.results.map((a) => a.id)); + setSelected((s) => new Set([...s].filter((id) => ids.has(id)))); + } } catch { if (!silent) notify("error", "加载审核队列失败"); } finally { @@ -71,15 +77,27 @@ export function AdminReviewsPage({ notify }: { notify: Notify }) { }; }, [load]); - async function retry(id: string) { - if (busy) return; + function toggleOne(id: string) { + setSelected((s) => { + const next = new Set(s); + if (next.has(id)) next.delete(id); else next.add(id); + return next; + }); + } + function toggleAll() { + setSelected((s) => (s.size === assets.length ? new Set() : new Set(assets.map((a) => a.id)))); + } + + async function submit(ids: string[]) { + if (busy || ids.length === 0) return; setBusy(true); try { - await adminApi.submitReviews([id]); - notify("success", "已重新送审"); + const res = await adminApi.submitReviews(ids); + notify("success", `已提交 ${res.submitted} 个资产送审`); + setSelected(new Set()); await load({ silent: true }); } catch { - notify("error", "重试失败"); + notify("error", "送审失败"); } finally { setBusy(false); } @@ -113,7 +131,7 @@ export function AdminReviewsPage({ notify }: { notify: Notify }) {

资产审核

-
{count} 个真人资产 · 未送审会自动提交火山审核,无需手动点送审
+
{count} 个真人资产 · 未送审会自动提交火山审核,也可勾选批量送审