From e2ec2d14af3ad091a168f7410375d62cb07b8b24 Mon Sep 17 00:00:00 2001 From: "Azmat@qq.com" Date: Tue, 25 Aug 2026 11:13:07 +0800 Subject: [PATCH] =?UTF-8?q?=E5=AE=8C=E6=88=90=E6=9E=81=E9=80=9F=E6=88=90?= =?UTF-8?q?=E5=93=81=E5=92=8C=E8=84=9A=E6=9C=AC=E4=BC=98=E5=8C=96?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- core/backend/apps/ai/script_agent.py | 86 +- core/backend/apps/ai/services.py | 41 +- .../apps/ai/test_script_duration_combo.py | 37 +- core/backend/apps/ai/test_video_digest.py | 175 +++- core/backend/apps/ai/urls.py | 2 + core/backend/apps/ai/video_digest.py | 385 ++++++++- core/backend/apps/ai/views.py | 34 +- core/backend/apps/billing/tests.py | 17 - core/backend/apps/common/celery_health.py | 56 +- .../backend/apps/common/test_celery_health.py | 41 + .../migrations/0009_quickcreatejob.py | 92 ++ core/backend/apps/projects/models.py | 39 +- core/backend/apps/projects/serializers.py | 133 ++- core/backend/apps/projects/services/export.py | 47 +- .../apps/projects/services/pipeline.py | 108 ++- .../apps/projects/services/quick_create.py | 806 ++++++++++++++++++ core/backend/apps/projects/tasks.py | 53 ++ .../apps/projects/test_quick_create.py | 490 +++++++++++ core/backend/apps/projects/tests.py | 20 + core/backend/apps/projects/views.py | 356 ++++++-- .../skills/ecommerce-video-script/SKILL.md | 29 +- .../references/checklist.md | 10 +- .../references/methodology.md | 16 +- .../references/playbooks/combo-matrix.md | 28 +- .../references/playbooks/format-drama.md | 2 +- .../references/playbooks/format-oral.md | 2 +- .../playbooks/structure-knowledge.md | 25 + .../references/playbooks/structure-promo.md | 24 + .../references/playbooks/structure-scene.md | 2 +- .../backend/skills/video-shot-digest/SKILL.md | 85 +- core/frontend/src/App.tsx | 28 +- core/frontend/src/api.ts | 86 +- core/frontend/src/components/app-shell.tsx | 3 + core/frontend/src/model-display.ts | 5 + core/frontend/src/pipeline-page.css | 16 + core/frontend/src/quick-create-page.css | 112 ++- core/frontend/src/routes/dashboard.tsx | 9 +- core/frontend/src/routes/pipeline.tsx | 103 ++- core/frontend/src/routes/products.tsx | 33 +- core/frontend/src/routes/projects.tsx | 19 +- core/frontend/src/routes/quick-create.tsx | 633 +++++++++++++- core/frontend/src/routes/route-config.ts | 6 +- core/frontend/src/routes/video-remix.tsx | 299 +++++-- core/frontend/src/script-setup.ts | 8 +- core/frontend/src/types.ts | 18 + core/frontend/src/video-remix-page.css | 547 ++++++++++++ 46 files changed, 4734 insertions(+), 432 deletions(-) create mode 100644 core/backend/apps/common/test_celery_health.py create mode 100644 core/backend/apps/projects/migrations/0009_quickcreatejob.py create mode 100644 core/backend/apps/projects/services/quick_create.py create mode 100644 core/backend/apps/projects/test_quick_create.py create mode 100644 core/backend/skills/ecommerce-video-script/references/playbooks/structure-knowledge.md create mode 100644 core/backend/skills/ecommerce-video-script/references/playbooks/structure-promo.md diff --git a/core/backend/apps/ai/script_agent.py b/core/backend/apps/ai/script_agent.py index c1641fc..600ffe2 100644 --- a/core/backend/apps/ai/script_agent.py +++ b/core/backend/apps/ai/script_agent.py @@ -52,6 +52,8 @@ VIDEO_STRUCTURES: dict[str, str] = { "contrast": "前后对比", "review": "测评验证", "scene": "场景种草", + "promo": "促销抢购", + "knowledge": "知识分享", } DEFAULT_PRESENTATION_FORMAT = "oral" DEFAULT_VIDEO_STRUCTURE = "pain" @@ -62,7 +64,9 @@ FORBIDDEN_COMBOS: set[tuple[str, str]] = {("drama", "review")} # 表现形式推荐的默认总时长:口播短平快,短剧要装下三幕,Vlog 要铺氛围。 FORMAT_DEFAULT_DURATION: dict[str, int] = {"oral": 30, "drama": 45, "vlog": 30} # 各结构能压到的最短总时长(须落在 15 秒步进上),见 playbooks/combo-matrix.md。 -STRUCTURE_MIN_DURATION: dict[str, int] = {"pain": 15, "contrast": 15, "review": 30, "scene": 30} +STRUCTURE_MIN_DURATION: dict[str, int] = { + "pain": 15, "contrast": 15, "review": 30, "scene": 30, "promo": 15, "knowledge": 30, +} # 设定卡人物 key → 中文(与前端 WIZ_PERSONA_LABEL / 模板 coerce_persona 对齐)。 PERSONA_LABELS: dict[str, str] = { @@ -83,11 +87,11 @@ PERSONA_BRIEFS: dict[str, str] = { } _PERSONA_KEY_BY_LABEL = {label: key for key, label in PERSONA_LABELS.items()} -# 成片有同期画面与自然停顿,3.5 字/秒会让 15 秒口播在约 10 秒时说完。 -# 电商真人口播以 3.6–4.5 字/秒为可用区间:既留出换气,也不会让画面空转。 -NARRATION_CHARS_PER_SECOND = 4.5 -NARRATION_CHARS_PER_SECOND_MIN = 3.6 -NARRATION_CHARS_HARD_CAP = 68 +# 电商口播需要同时承载观点、证据和转折;15 秒以 4.3–5.2 字/秒为可用区间。 +# 这比日常闲聊快,但仍给句间停顿和镜头切换留出空间。 +NARRATION_CHARS_PER_SECOND = 5.2 +NARRATION_CHARS_PER_SECOND_MIN = 4.3 +NARRATION_CHARS_HARD_CAP = 78 VISUAL_CHARS_MIN = 72 SHOT_BEATS_MIN = 3 BEAT_SPAN_RE = re.compile( @@ -161,7 +165,7 @@ def coerce_combo(fmt: str | None, structure: str | None) -> tuple[str, str]: def narration_limit(duration: int) -> int: - """这一镜旁白的字数上限:秒数 × 4.5,且不超过硬上限 68。""" + """这一镜旁白的字数上限:秒数 × 5.2,且不超过硬上限 78。""" return max(1, min(NARRATION_CHARS_HARD_CAP, int(duration * NARRATION_CHARS_PER_SECOND))) @@ -342,14 +346,15 @@ _CREATIVE_DIRECTION = """ 每个 segment 的 `visual` 采用下面的层级;这不是给用户看的散文,而是会原样交给故事板和视频模型的执行指令: ``` 【本镜任务】这一段要让观众看懂的变化或悬念。 -【声音】只写可发生的同期声 / 人声状态;没有配乐就明确写「无配乐,仅同期声」。 +【声音】台词/旁白状态;音效;背景音乐;字幕。没有就写「无」;没有配乐写「无配乐,仅同期声」。 【画面内容】 -0-3s:景别;构图;运镜;人物/商品动作;情绪或信息变化。 -3-8s:景别;构图;运镜;手与商品的空间关系;可见细节。 -8-12s:景别;构图;运镜;动作结果或卖点证据。 -12-15s:景别;构图;运镜;反应/悬念/自然收束。 +0-3s:景别;机位;运镜;人物/商品动作;情绪或信息变化。 +3-8s:景别;机位;运镜;手与商品的空间关系;可见细节。 +8-12s:景别;机位;运镜;动作结果或卖点证据。 +12-15s:景别;机位;运镜;反应/悬念/自然收束。 ``` -每条秒级分镜都要明确写出 **景别、构图、运镜、动作、信息变化** 五项中的至少四项。 +每条秒级分镜都要明确写出 **景别、机位、运镜、动作、信息变化** 五项中的至少四项。 +输入如果是 `【镜头 01】` 导演分镜稿,把原稿的景别、机位、运镜、动作、表情、音效、背景音乐、字幕、备注折进对应栏,不要压成一句画面摘要。 同一角色、商品、场景的外观一律引用 entities 里的既定设定,不在每一镜随意换发型、服装、包装、光线或地点。 """ @@ -492,7 +497,7 @@ def build_agent_messages( beats_line = ( f"【秒级分镜】每个 {SEGMENT_DURATION_MAX} 秒场必须拆成 3–5 个分镜,visual 必须按「导演说明书」写成多行:" "先写【本镜任务】、【声音】、【画面内容】,再写秒级分镜;" - f"每条格式`0-3s:景别;构图;运镜;谁在做什么;信息变化`,最后一条接到 {SEGMENT_DURATION_MAX}s。" + f"每条格式`0-3s:景别;机位;运镜;谁在做什么;信息变化`,最后一条接到 {SEGMENT_DURATION_MAX}s。" "每条写清手/商品/容器的空间关系和真实用法" "(茶=热水+蒸汽+茶汤变色,禁止茶包丢进冷白开;手从真实方向入画,禁止悬浮肢体)。" "禁止一句空画面撑满 15 秒。允许另给 beats 数组,后端会折进 visual。\n" @@ -519,6 +524,18 @@ def build_agent_messages( "【场景种草骨架】按镜走完:场景建立(商品先不出现)→商品被使用着入场→场景因它更好/软CTA。" "每镜写出具体时刻+地点+光/声音/物件。口播时每镜仍要说满字数,留白靠停顿不是靠少写字。\n" ) + elif structure == "promo": + structure_line = ( + "【促销抢购合规】只可使用商品资料中明确给出的价格、优惠、库存、赠品和活动时间;" + "没有明确数据时,绝不写原价、最低价、限时、限量、领券或赠品。" + "结尾只给一个清楚的购买入口和操作动作,不制造虚假的紧迫感。\n" + ) + elif structure == "knowledge": + structure_line = ( + "【知识分享骨架】先提出一个与商品相关的选购/使用误区或判断方法,再用实物与步骤证明," + "最后把商品作为符合该判断的具体实例。只能讲商品资料可支持的事实," + "禁止编造专业身份、数据、标准或功效承诺。\n" + ) combo_line = ( f"严格按已加载的「{PRESENTATION_FORMATS[fmt]} × {VIDEO_STRUCTURES[structure]}」套路写," f"不要串成别的结构的套话。\n" @@ -562,15 +579,30 @@ def build_agent_messages( ) else: extra_block = f"\n\n【补充要求】{extra}\n" if extra else "\n" - user = ( - "【任务】全自动(模式①):仅凭上面的商品事实与前置条件,按指定的表现形式与视频结构套路" - "自动定镜/选 tone/造 entity/填结构骨架。" - "不要另起一个空主题;钩子、痛点、卖点必须能对上这份商品,而不是品类套话。" - "每镜旁白要能撑满 15 秒;画面必须按秒拆分镜,够导演在 15 秒里切 3–5 刀。\n" - f"{head}" - f"{extra_block}\n" - "请按技能流程一次性产出 ScriptDraft。" - ) + if extra and _looks_like_shot_digest(extra): + user = ( + "【任务】参考视频改写:用户给了一份逐镜拆解稿。" + "照搬它的镜头顺序、每镜时长比例、景别、机位、运镜、人物动作和声音层次," + "把人物、商品、品牌、台词全部换成当前商品。" + "写 visual 时把拆解稿里的景别/机位/运镜/人物动作/表情/音效/背景音乐/字幕/备注" + "折进【声音】和【画面内容】的秒级分镜,不要压成一句画面摘要。" + "原稿写「无 / 不可见 / 听不清」的栏不要编造。" + "钩子、痛点、卖点必须能对上这份商品。" + "每镜旁白要能撑满指定时长;画面必须按秒拆分镜,够导演在 15 秒里切 3–5 刀。\n" + f"{head}" + f"{extra_block}\n" + "请按技能流程一次性产出 ScriptDraft。" + ) + else: + user = ( + "【任务】全自动(模式①):仅凭上面的商品事实与前置条件,按指定的表现形式与视频结构套路" + "自动定镜/选 tone/造 entity/填结构骨架。" + "不要另起一个空主题;钩子、痛点、卖点必须能对上这份商品,而不是品类套话。" + "每镜旁白要能撑满 15 秒;画面必须按秒拆分镜,够导演在 15 秒里切 3–5 刀。\n" + f"{head}" + f"{extra_block}\n" + "请按技能流程一次性产出 ScriptDraft。" + ) return [{"role": "system", "content": system}, {"role": "user", "content": user}] @@ -607,6 +639,14 @@ def _balanced_object(text: str) -> str | None: return None +def _looks_like_shot_digest(text: str) -> bool: + """用户贴进来的是视频提炼分镜稿,不是一句补充要求。""" + blob = text or "" + has_shot = "【镜头" in blob or ("【第" in blob and "镜】" in blob) + has_picture = "画面:" in blob or "画面:" in blob + return has_shot and has_picture + + def _looks_like_draft(blob: str) -> bool: try: d = json.loads(blob) diff --git a/core/backend/apps/ai/services.py b/core/backend/apps/ai/services.py index 6983363..c31222d 100644 --- a/core/backend/apps/ai/services.py +++ b/core/backend/apps/ai/services.py @@ -3225,8 +3225,31 @@ def collect_video_review_blockers(project, only_segment: "VideoSegment | None" = return blockers -def submit_video_segment(*, video_segment: VideoSegment, user, prompt: str) -> VideoSegmentVersion | None: - model_config = get_default_model(ModelConfig.Capability.VIDEO) +def submit_video_segment( + *, + video_segment: VideoSegment, + user, + prompt: str, + model_config_id=None, + aspect_ratio: str = "9:16", + resolution: str = "720p", +) -> VideoSegmentVersion | None: + model_config = None + if model_config_id: + model_config = ( + ModelConfig.objects.select_related("provider") + .filter( + id=model_config_id, + capability=ModelConfig.Capability.VIDEO, + status=ModelConfig.Status.ACTIVE, + provider__status="active", + ) + .first() + ) + if model_config is None: + raise ValueError("selected video model is unavailable") + else: + model_config = get_default_model(ModelConfig.Capability.VIDEO) if model_config is None: raise ValueError("no active video model configured") project = video_segment.project @@ -3244,15 +3267,15 @@ def submit_video_segment(*, video_segment: VideoSegment, user, prompt: str) -> V reference_images = [r["url"] for r in refs] final_prompt = build_video_segment_prompt(project, video_segment, scene, refs, prompt) - # 视频段 token 计量计价(与自由创作同一成本表+同一毛利):按 9:16/720p/目标时长预估, + # 视频段 token 计量计价(与自由创作同一成本表+同一毛利):按用户选定的比例/清晰度/目标时长预估, # 预留=积分×buffer,终态按火山真实 usage.total_tokens 结算(poll_video_segment true-up)。 # 这里终结了「视频 ¥1/段、成本 ¥15」的倒贴定价。 from apps.billing.pricing import quote_video_estimate, video_reserve_amount est_tokens, quote = quote_video_estimate( model_config, - aspect_ratio="9:16", - resolution="720p", + aspect_ratio=aspect_ratio, + resolution=resolution, duration=video_segment.target_duration_seconds, references=[], team=project.team, @@ -3269,8 +3292,8 @@ def submit_video_segment(*, video_segment: VideoSegment, user, prompt: str) -> V "endpoint": model_config.endpoint, "prompt": final_prompt, "duration": video_segment.target_duration_seconds, - "ratio": "9:16", - "resolution": "720p", + "ratio": aspect_ratio, + "resolution": resolution, "estimated_tokens": est_tokens, # 团队价格系数快照:按实结算用它,中途改价不影响在途任务(jimeng 同款纪律) "price_multiplier": quote.meta.get("price_multiplier", "1"), @@ -3288,8 +3311,8 @@ def submit_video_segment(*, video_segment: VideoSegment, user, prompt: str) -> V primary_model=model_config, prompt=final_prompt, duration=video_segment.target_duration_seconds, - ratio="9:16", - resolution="720p", + ratio=aspect_ratio, + resolution=resolution, reference_images=reference_images, request_summary={"video_segment_id": str(video_segment.id)}, ) diff --git a/core/backend/apps/ai/test_script_duration_combo.py b/core/backend/apps/ai/test_script_duration_combo.py index f6f0be9..219eb6b 100644 --- a/core/backend/apps/ai/test_script_duration_combo.py +++ b/core/backend/apps/ai/test_script_duration_combo.py @@ -249,6 +249,29 @@ class PromptAssemblyTests(SimpleTestCase): self.assertIn("禁止一句 20 字收工", user) self.assertIn("秒级分镜", user) + def test_video_digest_prompt_maps_camera_and_sound(self): + digest = ( + "片名:《雨夜归家》\n视频类型:剧情短片\n" + "【镜头 01】\n时间:00:00-00:04\n景别:城市远景\n运镜:缓慢向前推进\n" + "画面:深夜街道被雨水覆盖。\n台词/旁白:无\n音效:雨声\n" + ) + messages = build_agent_messages( + project=self._project(), + mode="auto", + user_prompt=digest, + selling_point_ids=["1"], + base_draft=None, + aspect_ratio="9:16", + total_duration=15, + persona="urban", + ) + user = messages[1]["content"] + self.assertIn("参考视频改写", user) + self.assertNotIn("全自动", user) + self.assertIn("景别/机位/运镜", user) + self.assertIn("音效/背景音乐", user) + self.assertIn("【镜头 01】", user) + def test_scene_oral_prompt_locks_skeleton_and_density(self): messages = build_agent_messages( project=self._project(), @@ -284,15 +307,15 @@ class PromptAssemblyTests(SimpleTestCase): class NarrationLimitTests(SimpleTestCase): def test_limit_scales_with_shot_length(self): - self.assertEqual(narration_limit(4), 18) - self.assertEqual(narration_limit(8), 36) - self.assertEqual(narration_limit(15), 67) + self.assertEqual(narration_limit(4), 20) + self.assertEqual(narration_limit(8), 41) + self.assertEqual(narration_limit(15), 78) def test_never_exceeds_hard_cap(self): - self.assertLessEqual(narration_limit(60), 68) + self.assertLessEqual(narration_limit(60), 78) - def test_fifteen_second_floor_is_about_fifty_four(self): - self.assertEqual(narration_floor(15), 54) + def test_fifteen_second_floor_is_about_sixty_four(self): + self.assertEqual(narration_floor(15), 64) self.assertLess(narration_floor(15), narration_limit(15)) @@ -408,7 +431,7 @@ class ComboTests(SimpleTestCase): def test_drama_cannot_pick_review(self): # 演出来的实测没有可信度 —— 这是唯一的禁用组合 self.assertNotIn("review", allowed_structures("drama")) - self.assertEqual(len(allowed_structures("drama")), 3) + self.assertEqual(len(allowed_structures("drama")), 5) def test_other_formats_allow_everything(self): for fmt in ("oral", "vlog"): diff --git a/core/backend/apps/ai/test_video_digest.py b/core/backend/apps/ai/test_video_digest.py index ffbfe9d..445adf3 100644 --- a/core/backend/apps/ai/test_video_digest.py +++ b/core/backend/apps/ai/test_video_digest.py @@ -10,7 +10,7 @@ import subprocess from unittest.mock import patch from django.core.files.uploadedfile import SimpleUploadedFile -from django.test import SimpleTestCase, TestCase +from django.test import SimpleTestCase, TestCase, override_settings from rest_framework.test import APIClient from apps.accounts.models import Team, TeamMember, User @@ -36,6 +36,14 @@ from apps.ai.video_digest import ( ) +def _shot_digest(shots=3, *, heading="lens"): + lines = ["片名:《示例》\n视频类型:口播带货\n时长:15秒\n整体风格:写实\n"] + for index in range(1, shots + 1): + mark = f"【镜头 {index:02d}】" if heading == "lens" else f"【第 {index} 镜】" + lines.append(f"{mark}\n时间:00:00-00:04\n画面:一位女生拿着商品\n台词/旁白:无\n") + return "".join(lines) + + class FramePlanTests(SimpleTestCase): def test_frame_count_scales_with_duration_within_bounds(self): for duration, expected in [(3, MIN_FRAMES), (10, MIN_FRAMES), (30, 15), (60, 30), (180, MAX_FRAMES)]: @@ -98,12 +106,15 @@ class MessageBuildTests(SimpleTestCase): def test_system_prompt_is_the_digest_skill(self): messages = build_digest_messages(self.frames, 10) self.assertEqual(messages[0]["role"], "system") - self.assertIn("解说词", messages[0]["content"]) + self.assertIn("台词/旁白", messages[0]["content"]) self.assertIn("覆盖全片", messages[0]["content"]) + self.assertIn("【镜头 01】", messages[0]["content"]) def test_user_prompt_asks_for_full_timeline(self): content = build_digest_messages(self.frames, 60)[1]["content"] self.assertIn("全片", content[0]["text"]) + self.assertIn("景别", content[0]["text"]) + self.assertIn("背景音乐", content[0]["text"]) def test_every_frame_is_inlined_with_its_timestamp(self): content = build_digest_messages(self.frames, 10)[1]["content"] @@ -121,17 +132,19 @@ class MessageBuildTests(SimpleTestCase): self.assertIn("蓝牙耳机 · 数码3C", content[0]["text"]) def test_skill_loads_from_disk(self): - self.assertIn("解说词", load_digest_skill()) + self.assertIn("台词/旁白", load_digest_skill()) + self.assertIn("景别", load_digest_skill()) class NativeVideoInputTests(SimpleTestCase): def test_short_clip_is_sent_as_whole_video(self): - video, frames, duration = digest_input_from_upload(_synth_clip(seconds=6)) + video, frames, duration, extras = digest_input_from_upload(_synth_clip(seconds=6)) self.assertIsNotNone(video) self.assertEqual(frames, []) self.assertTrue(video.mime.startswith("video/")) self.assertGreater(len(video.data), 100) self.assertAlmostEqual(duration, 6, delta=1) + self.assertIn("cover_jpeg", extras) def test_native_video_message_inlines_video_data_uri(self): video = DigestVideo(mime="video/mp4", data=b"\x00\x00fake") @@ -140,6 +153,8 @@ class NativeVideoInputTests(SimpleTestCase): self.assertEqual(len(media), 1) self.assertTrue(media[0]["image_url"]["url"].startswith("data:video/mp4;base64,")) self.assertIn("口播", content[0]["text"]) + self.assertIn("景别", content[0]["text"]) + self.assertIn("背景音乐", content[0]["text"]) class DigestValidationTests(SimpleTestCase): @@ -153,11 +168,15 @@ class DigestValidationTests(SimpleTestCase): validate_digest_text("这是一条很好的带货视频,节奏明快,画面精美。" * 5) def test_accepts_well_formed_digest(self): + good = _shot_digest() + self.assertEqual(validate_digest_text(f" {good} "), good.strip()) + + def test_accepts_legacy_shot_heading(self): good = "【整体结构】\n形式:口播\n" + "【第 1 镜】0-4 秒 · 钩子\n主体:一位女生\n" * 3 self.assertEqual(validate_digest_text(f" {good} "), good.strip()) def test_rejects_too_few_shots_for_a_long_clip(self): - stub = "【整体结构】\n形式:口播\n" + "【第 1 镜】0-4 秒 · 钩子\n主体:一位女生\n" * 3 + stub = _shot_digest(3) with self.assertRaises(ValueError): validate_digest_text(stub, duration=60, frame_count=30) @@ -324,7 +343,142 @@ class VideoDigestApiTests(TestCase): self.assertTrue(response.data["detail"]) self.assertEqual(response.data["error"]["operation"], "video_digest") + def _seed_model(self): + from decimal import Decimal + provider, _ = ModelProvider.objects.get_or_create( + name="remix-hist-relay", + defaults={"display_name": "Gemini 官转", "status": ModelProvider.Status.ACTIVE}, + ) + return ModelConfig.objects.create( + provider=provider, + name=DIGEST_VISION_MODEL_NAME, + display_name="Gemini 3.1 Pro 官转", + capability=ModelConfig.Capability.TEXT, + status=ModelConfig.Status.ACTIVE, + unit_price=Decimal("10"), + ) + + def _seed_digest_task(self, *, team=None, status=None, is_deleted=False, prompt="示例提示词", extra_key=""): + from apps.ai.models import AITask + + model = getattr(self, "_hist_model", None) or self._seed_model() + self._hist_model = model + task = AITask.objects.create( + team=team or self.team, + created_by=self.user, + project=None, + task_type=AITask.Type.VIDEO_DIGEST, + status=status or AITask.Status.SUCCEEDED, + model_config=model, + idempotency_key=f"digest-hist-{team.id if team else self.team.id}-{extra_key or AITask.objects.count()}", + request_payload={ + "feature": "video_remix", + "file_name": "参考视频.mp4", + "title": "轻醒咖啡场景种草视频", + "duration_seconds": 15, + "ratio": "9:16 竖屏", + "shot_count": 5, + "video_url": "https://cdn.example/coffee.mp4", + "cover_url": "https://cdn.example/coffee.jpg", + }, + response_payload={"digest": prompt, "prompt": prompt}, + is_deleted=is_deleted, + ) + return task + + def test_history_empty_by_default(self): + response = self.client.get("/api/ai/video-digest/") + self.assertEqual(response.status_code, 200) + self.assertEqual(response.data["results"], []) + self.assertEqual(response.data["total"], 0) + + def test_history_lists_succeeded_team_digests(self): + from apps.ai.models import AITask + + self._seed_digest_task(prompt="咖啡提示词", extra_key="ok") + self._seed_digest_task(status=AITask.Status.FAILED, extra_key="fail") + self._seed_digest_task(is_deleted=True, extra_key="del") + other_user = User.objects.create_user(username="remix-other", password="p") + other_team = Team.objects.create(name="Other", owner=other_user) + TeamMember.objects.create(team=other_team, user=other_user, role=TeamMember.Role.OWNER) + self._seed_digest_task(team=other_team, extra_key="other") + + response = self.client.get("/api/ai/video-digest/") + self.assertEqual(response.status_code, 200) + self.assertEqual(response.data["total"], 1) + item = response.data["results"][0] + self.assertEqual(item["title"], "轻醒咖啡场景种草视频") + self.assertEqual(item["status"], "已完成") + self.assertEqual(item["duration_label"], "00:15") + self.assertEqual(item["ratio"], "9:16 竖屏") + self.assertEqual(item["shots"], 5) + self.assertEqual(item["file_name"], "参考视频.mp4") + self.assertEqual(item["prompt"], "咖啡提示词") + self.assertEqual(item["video_url"], "https://cdn.example/coffee.mp4") + self.assertEqual(item["cover_url"], "https://cdn.example/coffee.jpg") + self.assertRegex(item["created_date"], r"^\d{4}-\d{2}-\d{2}$") + + @patch("apps.assets.storage.TosStorage") + def test_history_signs_video_url_from_object_key(self, storage_cls): + storage = storage_cls.return_value + storage.presigned_get_url.return_value = "https://signed.example/play.mp4" + storage.public_url.return_value = "https://cdn.example/cover.jpg" + task = self._seed_digest_task(extra_key="signed") + task.request_payload = { + **task.request_payload, + "video_url": "", + "video_key": "teams/1/video-digest/abc.mp4", + "cover_url": "", + "cover_key": "teams/1/video-digest/abc.jpg", + } + task.save(update_fields=["request_payload"]) + + response = self.client.get("/api/ai/video-digest/") + self.assertEqual(response.status_code, 200) + item = response.data["results"][0] + self.assertEqual(item["video_url"], "https://signed.example/play.mp4") + self.assertEqual(item["cover_url"], "https://cdn.example/cover.jpg") + storage.presigned_get_url.assert_called() + + def test_history_without_saved_source_has_empty_video_url(self): + task = self._seed_digest_task(extra_key="nosource") + payload = dict(task.request_payload) + payload.pop("video_url", None) + payload.pop("video_key", None) + task.request_payload = payload + task.save(update_fields=["request_payload"]) + + response = self.client.get("/api/ai/video-digest/") + self.assertEqual(response.status_code, 200) + self.assertEqual(response.data["results"][0]["video_url"], "") + + def test_save_prompt_updates_history(self): + task = self._seed_digest_task(prompt="旧提示词", extra_key="save") + response = self.client.patch( + f"/api/ai/video-digest/{task.id}/", + {"prompt": "新提示词"}, + format="json", + ) + self.assertEqual(response.status_code, 200) + self.assertEqual(response.data["prompt"], "新提示词") + task.refresh_from_db() + self.assertEqual(task.response_payload["prompt"], "新提示词") + self.assertEqual(task.response_payload["digest"], "旧提示词") + + def test_save_prompt_rejects_empty_and_missing(self): + task = self._seed_digest_task(extra_key="empty") + empty = self.client.patch(f"/api/ai/video-digest/{task.id}/", {"prompt": " "}, format="json") + self.assertEqual(empty.status_code, 400) + missing = self.client.patch( + "/api/ai/video-digest/00000000-0000-0000-0000-000000000099/", + {"prompt": "还在"}, + format="json", + ) + self.assertEqual(missing.status_code, 404) + + +@override_settings(CACHES={"default": {"BACKEND": "django.core.cache.backends.locmem.LocMemCache"}}) class VideoDigestBillingTests(TestCase): """提炼提示词按功能价 30 积分结算,不跟 Gemini 文本单价。""" @@ -362,7 +516,7 @@ class VideoDigestBillingTests(TestCase): with ( patch( "apps.ai.video_digest.digest_input_from_upload", - return_value=(None, [VideoFrame(at_seconds=2, jpeg=b"\xff\xd8x")], 15.0), + return_value=(None, [VideoFrame(at_seconds=2, jpeg=b"\xff\xd8x")], 15.0, {}), ), patch( "apps.ai.services.execute_routed_text_request", @@ -380,6 +534,13 @@ class VideoDigestBillingTests(TestCase): from decimal import Decimal from apps.ai.models import AITask + from apps.billing.pricing import VIDEO_DIGEST_POINTS, quote_video_digest + + self.assertEqual(VIDEO_DIGEST_POINTS, Decimal("30")) + self.assertEqual(quote_video_digest(model_config=self.model).points, Decimal("30")) + self.team.price_multiplier = Decimal("0.80") + self.assertEqual(quote_video_digest(team=self.team, model_config=self.model).points, Decimal("24")) + self.team.price_multiplier = Decimal("1") result = self._run_digest() self.account.refresh_from_db() @@ -401,7 +562,7 @@ class VideoDigestBillingTests(TestCase): with ( patch( "apps.ai.video_digest.digest_input_from_upload", - return_value=(None, [VideoFrame(at_seconds=2, jpeg=b"\xff\xd8x")], 15.0), + return_value=(None, [VideoFrame(at_seconds=2, jpeg=b"\xff\xd8x")], 15.0, {}), ), patch("apps.ai.services.execute_routed_text_request") as mocked, ): diff --git a/core/backend/apps/ai/urls.py b/core/backend/apps/ai/urls.py index 2c9ae1d..8605b0e 100644 --- a/core/backend/apps/ai/urls.py +++ b/core/backend/apps/ai/urls.py @@ -3,6 +3,7 @@ from rest_framework.routers import DefaultRouter from .views import ( AITaskViewSet, + VideoDigestDetailView, VideoDigestView, FreeVideoDetailView, FreeVideoFavoriteView, @@ -25,6 +26,7 @@ router.register("image-conversations", ImageConversationViewSet, basename="image urlpatterns = [ path("generate-image/", GenerateImageView.as_view(), name="ai-generate-image"), path("video-digest/", VideoDigestView.as_view(), name="ai-video-digest"), + path("video-digest//", VideoDigestDetailView.as_view(), name="ai-video-digest-detail"), path("free-video/", FreeVideoView.as_view(), name="ai-free-video"), path("free-video/trash/", FreeVideoTrashView.as_view(), name="ai-free-video-trash"), path("free-video/upload/", FreeVideoUploadView.as_view(), name="ai-free-video-upload"), diff --git a/core/backend/apps/ai/video_digest.py b/core/backend/apps/ai/video_digest.py index f9ad66d..d5fbc08 100644 --- a/core/backend/apps/ai/video_digest.py +++ b/core/backend/apps/ai/video_digest.py @@ -46,8 +46,8 @@ MIN_FRAMES = 8 MAX_FRAMES = 36 FRAME_WIDTH = 768 FRAME_QUALITY = 3 -DIGEST_MAX_TOKENS = 8192 -_SHOT_MARK = re.compile(r"【第\s*\d+\s*镜】") +DIGEST_MAX_TOKENS = 12288 +_SHOT_MARK = re.compile(r"【(?:镜头\s*\d+|第\s*\d+\s*镜)】") _FFMPEG_TIMEOUT = 60 @@ -93,9 +93,10 @@ def load_digest_skill() -> str: return main.read_text(encoding="utf-8") # 兜底:skill 丢了也别整条链路挂掉,退化成一句话提示词(产出会明显变差,交接文档已注明须带 skills 目录) return ( - "你是分镜拆解 agent。输入是一条电商短视频的完整文件,含画面和口播。" - "必须覆盖全片,从 0 秒写到片尾,有几镜写几镜,不要概括成几大段。" - "每镜写画面和解说词,解说词按听到的口播逐字写。输出中文纯文本。" + "你是分镜拆解 agent。输入是一条短视频的完整文件,含画面和音轨。" + "必须覆盖全片,从 00:00 写到片尾,有几镜写几镜,不要概括成几大段。" + "每镜按【镜头 01】写出时间、时长、景别、机位、运镜、画面、人物动作、人物表情、" + "台词/旁白、音效、背景音乐、字幕、备注。没有就写无。输出中文纯文本。" ) @@ -130,6 +131,73 @@ def probe_duration(path: str | Path) -> float: return duration +def probe_video_size(path: str | Path) -> tuple[int, int]: + """ffprobe 读画面宽高。读不到返回 0,0,不挡拆解。""" + try: + out = subprocess.run( + [ + _binary("ffprobe"), "-v", "error", + "-select_streams", "v:0", + "-show_entries", "stream=width,height", + "-of", "csv=p=0:s=x", + str(path), + ], + capture_output=True, timeout=_FFMPEG_TIMEOUT, check=True, + ).stdout.decode("utf-8", errors="replace").strip() + width_s, height_s = out.split("x", 1) + return max(0, int(width_s)), max(0, int(height_s)) + except Exception: # noqa: BLE001 + return 0, 0 + + +def extract_cover_jpeg(path: str | Path, duration: float) -> bytes: + """抽一帧作历史封面。失败返回空字节,不挡拆解。""" + try: + ffmpeg = _binary("ffmpeg") + except VideoDigestError: + return b"" + at = max(0.0, min(float(duration) * 0.35, max(0.0, float(duration) - 0.15))) + try: + done = subprocess.run( + [ + ffmpeg, "-v", "error", "-ss", f"{at:.2f}", "-i", str(path), + "-frames:v", "1", "-vf", "scale=640:-2", + "-q:v", "4", "-f", "image2", "-", + ], + capture_output=True, timeout=_FFMPEG_TIMEOUT, check=True, + ) + except Exception: # noqa: BLE001 + return b"" + return done.stdout or b"" + + +def ratio_label(width: int, height: int) -> str: + if not width or not height: + return "" + ratio = width / height + if abs(ratio - 9 / 16) < 0.08: + return "9:16 竖屏" + if abs(ratio - 16 / 9) < 0.08: + return "16:9 横屏" + if abs(ratio - 1) < 0.08: + return "1:1" + return "横屏" if width > height else "竖屏" + + +def title_from_filename(name: str) -> str: + stem = Path(name or "").stem.strip() + return (stem or "参考视频")[:80] + + +def duration_clock(seconds: float) -> str: + total = max(0, int(round(float(seconds or 0)))) + return f"{total // 60:02d}:{total % 60:02d}" + + +def shot_count(text: str) -> int: + return len(_SHOT_MARK.findall(text or "")) + + def plan_frame_times(duration: float) -> list[int]: """均匀采样时间点。取每段的**中点**,避开首尾黑场与片尾卡片。""" count = max(MIN_FRAMES, min(MAX_FRAMES, math.ceil(duration / SECONDS_PER_FRAME))) @@ -223,6 +291,60 @@ def _compress_video(path: str) -> bytes | None: Path(out.name).unlink(missing_ok=True) +def _is_browser_playable(path: str) -> bool: + """Chrome 播不了微信常见的 HEVC。H.264 + AAC/无音轨才直接存。""" + try: + out = subprocess.run( + [ + _binary("ffprobe"), "-v", "error", + "-show_entries", "stream=codec_name,codec_type", + "-of", "json", + str(path), + ], + capture_output=True, timeout=_FFMPEG_TIMEOUT, check=True, + ) + streams = json.loads(out.stdout).get("streams") or [] + except Exception: # noqa: BLE001 + return False + video_ok = False + audio_ok = True + for stream in streams: + kind = stream.get("codec_type") + codec = str(stream.get("codec_name") or "").lower() + if kind == "video": + video_ok = codec in {"h264", "vp8", "vp9", "av1"} + elif kind == "audio": + audio_ok = codec in {"aac", "mp3", "opus", "vorbis"} + return video_ok and audio_ok + + +def _prepare_browser_video(path: str, suffix: str) -> tuple[str, str, bool]: + """转成浏览器能播的 H.264 AAC。失败退回原片。第三项表示调用方要删临时文件。""" + ext = suffix if str(suffix).startswith(".") else f".{suffix or 'mp4'}" + if _is_browser_playable(path): + return path, ext, False + ffmpeg = _binary("ffmpeg") + out = tempfile.NamedTemporaryFile(suffix=".mp4", delete=False) + out.close() + try: + done = subprocess.run( + [ + ffmpeg, "-v", "error", "-y", "-i", path, + "-c:v", "libx264", "-preset", "veryfast", "-crf", "23", + "-c:a", "aac", "-b:a", "128k", + "-movflags", "+faststart", out.name, + ], + capture_output=True, timeout=_FFMPEG_TIMEOUT * 3, + ) + if done.returncode != 0 or not Path(out.name).is_file() or Path(out.name).stat().st_size <= 0: + Path(out.name).unlink(missing_ok=True) + return path, ext, False + return out.name, ".mp4", True + except Exception: # noqa: BLE001 — 转码失败仍存原片,总比历史卡不能播好 + Path(out.name).unlink(missing_ok=True) + return path, ext, False + + def _native_video(path: str, size: int, suffix: str) -> DigestVideo | None: mime = _SUFFIX_MIME.get(suffix.lower(), "video/mp4") if size <= INLINE_VIDEO_MAX_BYTES: @@ -233,17 +355,37 @@ def _native_video(path: str, size: int, suffix: str) -> DigestVideo | None: return None -def digest_input_from_upload(upload) -> tuple[DigestVideo | None, list[VideoFrame], float]: - """优先整段视频(含音轨);塞不进请求才抽帧。""" +def digest_input_from_upload( + upload, + *, + keep_source: bool = False, +) -> tuple[DigestVideo | None, list[VideoFrame], float, dict]: + """优先整段视频(含音轨);塞不进请求才抽帧。顺带抽出历史卡要用的封面/宽高。 + + keep_source=True 时不删临时文件,调用方上传原片后再删。 + """ path = "" + keep = False + original_name = Path(getattr(upload, "name", "") or "参考视频.mp4").name or "参考视频.mp4" try: path, suffix, size, duration = _materialize_upload(upload) + width, height = probe_video_size(path) + cover_jpeg = extract_cover_jpeg(path, duration) video = _native_video(path, size, suffix) - if video is not None: - return video, [], duration - return None, extract_frames(path, plan_frame_times(duration)), duration + frames = [] if video is not None else extract_frames(path, plan_frame_times(duration)) + extras = { + "file_name": original_name, + "file_size": size, + "width": width, + "height": height, + "cover_jpeg": cover_jpeg, + "suffix": suffix, + "source_path": path if keep_source else "", + } + keep = keep_source + return video, frames, duration, extras finally: - if path: + if path and not keep: Path(path).unlink(missing_ok=True) @@ -267,20 +409,28 @@ def build_digest_messages( *, product_hint: str = "", video: DigestVideo | None = None, + aspect_ratio: str = "", + file_title: str = "", ) -> list[dict]: """system = 拆解 skill;user = 完整视频(优先)或抽帧。""" frames = frames or [] + measured = f"实测时长约 {round(duration)} 秒" + if aspect_ratio: + measured += f",画面比例 {aspect_ratio}" + title_hint = f"文件名可参考片名:《{file_title}》。" if file_title else "" if video is not None: head = [ - f"这是一条时长约 {round(duration)} 秒的电商带货短视频的**完整文件**(含画面和口播音轨)。", - "请按技能还原全片分镜:从 0 秒写到片尾,有几镜写几镜。", - "解说词按听到的口播逐字写;画面上的花字一并写进画面。", + f"这是一条{measured}的短视频**完整文件**(含画面和口播音轨)。{title_hint}", + "请按技能还原全片导演分镜稿:从 00:00 写到片尾,有几镜写几镜。", + "每镜必须写齐时间、时长、景别、机位、运镜、画面、人物动作、人物表情、台词/旁白、音效、背景音乐、字幕、备注。", + "台词/旁白按听到的口播逐字写;画面上的花字写进字幕。", ] else: head = [ - f"这是一条时长约 {round(duration)} 秒的电商带货短视频,", - f"按时间顺序均匀抽了 {len(frames)} 帧。每帧图前面标了它在原片中的时间点。", - "请按技能还原**全片**分镜:从 0 秒写到片尾,有几镜写几镜。", + f"这是一条{measured}的短视频,", + f"按时间顺序均匀抽了 {len(frames)} 帧。每帧图前面标了它在原片中的时间点。{title_hint}", + "请按技能还原**全片**导演分镜稿:从 00:00 写到片尾,有几镜写几镜。", + "每镜必须写齐时间、时长、景别、机位、运镜、画面、人物动作、人物表情、台词/旁白、音效、背景音乐、字幕、备注。", ] if product_hint: head.append(f"用户接下来想用这条片子的结构去拍自己的商品:{product_hint}。") @@ -429,6 +579,127 @@ def digest_team_video(*, team, user, upload, model_config_id=None) -> dict: ) +def _store_digest_cover(*, team, jpeg: bytes) -> tuple[str, str]: + """封面传到 TOS。失败返回空,历史卡走占位底。""" + from io import BytesIO + + from apps.assets.storage import TosStorage + + if not jpeg: + return "", "" + key = f"teams/{team.id}/video-digest/{uuid.uuid4()}.jpg" + storage = TosStorage() + stored = storage.upload_fileobj(fileobj=BytesIO(jpeg), object_key=key, content_type="image/jpeg") + return stored.object_key, storage.public_url(object_key=stored.object_key) + + +def _store_digest_video(*, team, path: str, suffix: str) -> tuple[str, str]: + """原片(优先转成 H.264)传到 TOS,历史封面点击才能播。失败返回空,不挡拆解。""" + from apps.assets.storage import TosStorage + + if not path or not Path(path).is_file(): + return "", "" + upload_path, ext, ephemeral = _prepare_browser_video(path, suffix) + try: + if ext.lower() not in ALLOWED_SUFFIXES: + ext = ".mp4" + mime = _SUFFIX_MIME.get(ext.lower(), "video/mp4") + key = f"teams/{team.id}/video-digest/{uuid.uuid4()}{ext.lower()}" + storage = TosStorage() + with Path(upload_path).open("rb") as fileobj: + stored = storage.upload_fileobj(fileobj=fileobj, object_key=key, content_type=mime) + return stored.object_key, storage.public_url(object_key=stored.object_key) + finally: + if ephemeral: + Path(upload_path).unlink(missing_ok=True) + + +def _media_url_from_payload(req: dict, url_key: str, object_key_name: str, *, signed: bool = False) -> str: + stored = str(req.get(url_key) or "").strip() + key = str(req.get(object_key_name) or "").strip() + if not key: + return stored + try: + from apps.assets.storage import TosStorage + + storage = TosStorage() + if signed: + return storage.presigned_get_url(object_key=key, expires_in=6 * 3600) + return storage.public_url(object_key=key) + except Exception: # noqa: BLE001 + return stored + + +def serialize_digest_history(task) -> dict: + from django.utils import timezone + + from apps.ai.models import AITask + + req = task.request_payload or {} + resp = task.response_payload or {} + prompt = str(resp.get("prompt") or resp.get("digest") or "").strip() + duration = float(req.get("duration_seconds") or 0) + file_name = str(req.get("file_name") or "") or "参考视频.mp4" + title = str(req.get("title") or "").strip() or title_from_filename(file_name) + shots = int(req.get("shot_count") or 0) or shot_count(prompt) + created = timezone.localtime(task.created_at) if task.created_at else timezone.now() + return { + "id": str(task.id), + "title": title, + "status": "已完成" if task.status == AITask.Status.SUCCEEDED else "失败", + "duration": int(round(duration)), + "duration_label": duration_clock(duration), + "ratio": str(req.get("ratio") or ""), + "shots": shots, + "file_name": file_name, + "cover_url": _media_url_from_payload(req, "cover_url", "cover_key"), + "video_url": _media_url_from_payload(req, "video_url", "video_key", signed=True), + "prompt": prompt, + "created_at": task.created_at.isoformat() if task.created_at else "", + "created_date": created.strftime("%Y-%m-%d"), + } + + +def list_team_digest_history(*, team, limit: int = 50) -> list[dict]: + from apps.ai.models import AITask + + qs = ( + AITask.objects.filter( + team=team, + task_type=AITask.Type.VIDEO_DIGEST, + project__isnull=True, + status=AITask.Status.SUCCEEDED, + is_deleted=False, + purged_at__isnull=True, + ) + .order_by("-created_at")[: max(1, min(int(limit), 50))] + ) + return [serialize_digest_history(task) for task in qs] + + +def save_digest_prompt(*, team, task_id, prompt: str) -> dict | None: + from apps.ai.models import AITask + + cleaned = (prompt or "").strip() + if not cleaned: + raise VideoDigestError("提示词不能为空") + task = AITask.objects.filter( + id=task_id, + team=team, + task_type=AITask.Type.VIDEO_DIGEST, + project__isnull=True, + is_deleted=False, + purged_at__isnull=True, + ).first() + if task is None: + return None + payload = dict(task.response_payload or {}) + payload["prompt"] = cleaned[:32000] + task.response_payload = payload + task.save(update_fields=["response_payload", "updated_at"]) + return serialize_digest_history(task) + + def _digest_video(*, team, user, upload, project=None, product_hint="", model_config_id=None) -> dict: """上传视频 → 分镜稿。抽帧在建任务之前做,文件不合格不占积分。""" from django.db import transaction @@ -439,10 +710,20 @@ def _digest_video(*, team, user, upload, project=None, product_hint="", model_co from apps.billing.pricing import quote_video_digest from apps.billing.services.ledger import charge_reserved_credit, reserve_credit - video, frames, duration = digest_input_from_upload(upload) + video, frames, duration, extras = digest_input_from_upload( + upload, keep_source=(project is None) + ) + extras = extras or {} + source_path = str(extras.get("source_path") or "") + file_name = str(extras.get("file_name") or getattr(upload, "name", "") or "参考视频.mp4") + width = int(extras.get("width") or 0) + height = int(extras.get("height") or 0) + file_size = int(extras.get("file_size") or getattr(upload, "size", 0) or 0) model_config = resolve_digest_model_config(preferred_id=model_config_id) if model_config is None: + if source_path: + Path(source_path).unlink(missing_ok=True) raise VideoDigestError("视频提炼需要 Gemini 3.1 Pro(会看图),当前没有启用,请联系管理员") logger.info( "video digest using %s:%s (%s) input=%s duration=%.1fs bytes=%s frames=%s", @@ -456,7 +737,12 @@ def _digest_video(*, team, user, upload, project=None, product_hint="", model_co ) messages = build_digest_messages( - frames, duration, product_hint=product_hint, video=video + frames, + duration, + product_hint=product_hint, + video=video, + aspect_ratio=ratio_label(width, height), + file_title=title_from_filename(file_name), ) request_payload = { "model": model_config.name, @@ -467,6 +753,12 @@ def _digest_video(*, team, user, upload, project=None, product_hint="", model_co "frame_count": len(frames), "video_bytes": len(video.data) if video is not None else 0, "frame_times": [f.at_seconds for f in frames], + "file_name": file_name, + "title": title_from_filename(file_name), + "file_size": file_size, + "width": width, + "height": height, + "ratio": ratio_label(width, height), } quote = quote_video_digest(team=team, model_config=model_config) @@ -502,6 +794,8 @@ def _digest_video(*, team, user, upload, project=None, product_hint="", model_co task.status = AITask.Status.RESERVED task.save(update_fields=["status", "updated_at"]) except ValueError as exc: + if source_path: + Path(source_path).unlink(missing_ok=True) if "insufficient credit" in str(exc).lower(): raise VideoDigestError("团队余额不足,请充值后重试") from exc raise VideoDigestError(str(exc)) from exc @@ -532,15 +826,56 @@ def _digest_video(*, team, user, upload, project=None, product_hint="", model_co ) _text, _response, digest = routed.value except Exception as exc: # noqa: BLE001 + if source_path: + Path(source_path).unlink(missing_ok=True) _fail_digest_task(task, reservation, str(exc)) raise + cover_key, cover_url, video_key, video_url = "", "", "", "" + if project is None: + try: + cover_key, cover_url = _store_digest_cover(team=team, jpeg=extras.get("cover_jpeg") or b"") + except Exception: # noqa: BLE001 — 封面失败不挡拆解结果 + logger.warning("video digest cover upload failed", exc_info=True) + try: + video_key, video_url = _store_digest_video( + team=team, + path=source_path, + suffix=str(extras.get("suffix") or ".mp4"), + ) + except Exception: # noqa: BLE001 — 原片失败仍可看提示词,封面不能播 + logger.warning("video digest source upload failed", exc_info=True) + if source_path: + Path(source_path).unlink(missing_ok=True) + source_path = "" + + shots = shot_count(digest) + request_payload = { + **(task.request_payload or {}), + **request_payload, + "shot_count": shots, + "cover_key": cover_key, + "cover_url": cover_url, + "video_key": video_key, + "video_url": video_url, + } + with transaction.atomic(): task.status = AITask.Status.SUCCEEDED - task.response_payload = {"digest": digest[:32000]} + task.request_payload = request_payload + task.response_payload = {"digest": digest[:32000], "prompt": digest[:32000]} task.actual_cost = task.estimated_cost task.completed_at = timezone.now() - task.save(update_fields=["status", "response_payload", "actual_cost", "completed_at", "updated_at"]) + task.save( + update_fields=[ + "status", + "request_payload", + "response_payload", + "actual_cost", + "completed_at", + "updated_at", + ] + ) charge_reserved_credit(reservation=reservation, actual_amount=task.actual_cost) return { @@ -551,6 +886,14 @@ def _digest_video(*, team, user, upload, project=None, product_hint="", model_co "duration": round(duration, 1), "task_id": str(task.id), "estimated_cost": str(task.estimated_cost), + "title": request_payload.get("title") or title_from_filename(file_name), + "file_name": file_name, + "ratio": request_payload.get("ratio") or "", + "shots": shots, + "cover_url": cover_url, + "video_url": video_url, + "width": width, + "height": height, } diff --git a/core/backend/apps/ai/views.py b/core/backend/apps/ai/views.py index c6770c3..c99d8f2 100644 --- a/core/backend/apps/ai/views.py +++ b/core/backend/apps/ai/views.py @@ -7,7 +7,7 @@ from django.utils import timezone from rest_framework import status from rest_framework.decorators import action from rest_framework.exceptions import ValidationError -from rest_framework.parsers import FormParser, MultiPartParser +from rest_framework.parsers import FormParser, JSONParser, MultiPartParser from rest_framework.response import Response from rest_framework.views import APIView from rest_framework.viewsets import ModelViewSet, ReadOnlyModelViewSet @@ -700,9 +700,17 @@ class VideoDigestView(APIView): """视频提炼 · 上传参考视频提炼分镜稿(不绑项目)。 POST /api/ai/video-digest/ multipart file → 中文分镜稿。慢(30~60 秒),固定 30 积分/次,失败退还。 + GET /api/ai/video-digest/ 本团队已完成的提炼历史(新→旧)。 """ - parser_classes = [MultiPartParser, FormParser] + parser_classes = [MultiPartParser, FormParser, JSONParser] + + def get(self, request): + from .video_digest import list_team_digest_history + + team = get_current_team(request.user) + results = list_team_digest_history(team=team) + return Response({"results": results, "total": len(results)}) def post(self, request): from .video_digest import VideoDigestError, digest_team_video @@ -730,6 +738,28 @@ class VideoDigestView(APIView): return Response({"name": getattr(upload, "name", "") or "参考视频", **result}) +class VideoDigestDetailView(APIView): + """PATCH /api/ai/video-digest// 保存编辑后的提示词到这条历史。""" + + parser_classes = [JSONParser, FormParser] + + def patch(self, request, task_id): + from .video_digest import VideoDigestError, save_digest_prompt + + team = get_current_team(request.user) + try: + item = save_digest_prompt( + team=team, + task_id=task_id, + prompt=str(request.data.get("prompt") or ""), + ) + except VideoDigestError as exc: + return Response({"detail": str(exc)}, status=status.HTTP_400_BAD_REQUEST) + if item is None: + return Response({"detail": "记录不存在"}, status=status.HTTP_404_NOT_FOUND) + return Response(item) + + class FreeVideoView(APIView): """自由创作·视频生成(不绑项目,universal 全能参考 / keyframe 首尾帧)。 diff --git a/core/backend/apps/billing/tests.py b/core/backend/apps/billing/tests.py index d77a6c8..532b660 100644 --- a/core/backend/apps/billing/tests.py +++ b/core/backend/apps/billing/tests.py @@ -231,15 +231,6 @@ class PricingEngineTests(TestCase): # 多单位(单图批量按张) self.assertEqual(quote_flat(self.text_model, units=3).points, Decimal("30")) - def test_video_digest_is_fixed_thirty_not_model_unit_price(self): - from apps.billing.pricing import VIDEO_DIGEST_POINTS, quote_video_digest - - self.assertEqual(VIDEO_DIGEST_POINTS, Decimal("30")) - quote = quote_video_digest(model_config=self.text_model) - self.assertEqual(quote.points, Decimal("30")) - self.assertEqual(quote.meta["rule"], "video_digest_flat") - self.assertEqual(quote.meta["unit_points"], "30") - def test_flat_fallback_when_unit_price_unset(self): from apps.billing.pricing import FLAT_FALLBACK_POINTS, quote_flat @@ -423,14 +414,6 @@ class TeamPriceMultiplierTests(TestCase): # meta.rate 是汇率快照契约:create_ai_task 落 payload.points_per_yuan_snapshot,毛利报表用它防汇率漂移 self.assertEqual(Decimal(quote.meta["rate"]), Decimal("10")) - def test_video_digest_applies_multiplier_to_feature_price(self): - from apps.billing.pricing import quote_video_digest - - # 30 × 0.8 = 24;不跟模型 unit_price=20 - quote = quote_video_digest(team=self.team, model_config=self.image_model) - self.assertEqual(quote.points, Decimal("24")) - self.assertEqual(quote_video_digest().points, Decimal("30")) - def test_two_step_rounding(self): from apps.billing.pricing import quote_video_from_cost diff --git a/core/backend/apps/common/celery_health.py b/core/backend/apps/common/celery_health.py index 04fe934..5aa821f 100644 --- a/core/backend/apps/common/celery_health.py +++ b/core/backend/apps/common/celery_health.py @@ -27,13 +27,39 @@ class WorkerUnavailable(APIException): _OK_TTL = 30.0 _FAIL_TTL = 5.0 _cache = {"ok": False, "expires": 0.0} +# True / False / None(inspect 没答上来,不当成「未部署」) +_registered_task_cache: dict[str, tuple[bool | None, float]] = {} + + +def _task_name_listed(task_name: str, task_names) -> bool: + """Celery inspect.registered() 可能给全名、短名,或带 [rate] 后缀。""" + short = task_name.rsplit(".", 1)[-1] + for raw in task_names or []: + name = str(raw).split("[", 1)[0].strip() + if name in {task_name, short} or name.endswith("." + short): + return True + return False + + +def _inspect_task_registered(task_name: str) -> bool | None: + """True 已注册 / False 明确没有 / None 问不到(超时或空应答)。""" + try: + from airshelf.celery import app as celery_app + + registered = celery_app.control.inspect(timeout=2.5).registered() + except Exception: # noqa: BLE001 — inspect 失败不等于任务没部署 + return None + if not registered: + return None + return any(_task_name_listed(task_name, names) for names in registered.values()) def _ping_workers() -> bool: from airshelf.celery import app as celery_app - # limit=1:收到第一个 worker 回包立即返回,不傻等满 timeout - return bool(celery_app.control.ping(timeout=1.0, limit=1)) + # limit=1:收到第一个 worker 回包立即返回。本机连火山 Redis 往返常超过 1s, + # 超时会被误判成 worker 未运行,生成入口整页锁死。 + return bool(celery_app.control.ping(timeout=3.0, limit=1)) def celery_worker_available() -> bool: @@ -55,3 +81,29 @@ def require_worker() -> None: """生成类提交入口的前置检查:无 worker 直接 503,不让任务出门。""" if not celery_worker_available(): raise WorkerUnavailable() + + +def require_worker_task(task_name: str) -> None: + """确认在线 Worker 已加载指定任务,避免新功能被旧进程静默丢弃。 + + Worker 在线不代表它已经加载了最新 tasks.py。Celery 收到未注册任务会直接丢弃消息, + 页面只能看到永久「生成中」。提交极速成片等新编排前,额外确认任务名已注册。 + + inspect 超时/空应答不能当成「未部署」——本机常见 ping 通、inspect 1s 问不到, + 会误报「正在升级」。只有明确看到已注册列表且没有该任务时才拦截。 + """ + if getattr(settings, "CELERY_TASK_ALWAYS_EAGER", False): + return + require_worker() + now = time.monotonic() + cached = _registered_task_cache.get(task_name) + if cached and now < cached[1]: + available = cached[0] + else: + available = _inspect_task_registered(task_name) + ttl = _OK_TTL if available else _FAIL_TTL + _registered_task_cache[task_name] = (available, now + ttl) + if available is False: + exc = WorkerUnavailable() + exc.detail = "极速成片后台任务尚未加载,请重启 Celery worker 后再试。" + raise exc diff --git a/core/backend/apps/common/test_celery_health.py b/core/backend/apps/common/test_celery_health.py new file mode 100644 index 0000000..44f0b9b --- /dev/null +++ b/core/backend/apps/common/test_celery_health.py @@ -0,0 +1,41 @@ +from unittest.mock import Mock, patch + +from django.test import SimpleTestCase, override_settings +from rest_framework.exceptions import APIException + +from apps.common import celery_health +from apps.common.celery_health import require_worker_task + + +class RequireWorkerTaskTests(SimpleTestCase): + def setUp(self): + celery_health._registered_task_cache.clear() + celery_health._cache["expires"] = 0.0 + + def tearDown(self): + celery_health._registered_task_cache.clear() + celery_health._cache["expires"] = 0.0 + + def test_registered_name_accepts_short_name_and_rate_suffix(self): + listed = ["apps.projects.tasks.advance_quick_create_task[1/s]"] + self.assertTrue( + celery_health._task_name_listed("apps.projects.tasks.advance_quick_create_task", listed) + ) + self.assertTrue(celery_health._task_name_listed("advance_quick_create_task", listed)) + self.assertFalse(celery_health._task_name_listed("apps.projects.tasks.missing_task", listed)) + + @override_settings(CELERY_TASK_ALWAYS_EAGER=False) + @patch("apps.common.celery_health.celery_worker_available", return_value=True) + @patch("apps.common.celery_health._inspect_task_registered", return_value=None) + def test_inspect_unknown_does_not_block(self, _inspect, _available): + require_worker_task("apps.projects.tasks.advance_quick_create_task") + + @override_settings(CELERY_TASK_ALWAYS_EAGER=False) + @patch("apps.common.celery_health.celery_worker_available", return_value=True) + @patch("apps.common.celery_health._inspect_task_registered", return_value=False) + def test_missing_task_blocks_with_restart_hint(self, _inspect, _available): + with self.assertRaises(APIException) as raised: + require_worker_task("apps.projects.tasks.advance_quick_create_task") + self.assertEqual(raised.exception.status_code, 503) + self.assertIn("重启", str(raised.exception.detail)) + self.assertNotIn("正在升级", str(raised.exception.detail)) diff --git a/core/backend/apps/projects/migrations/0009_quickcreatejob.py b/core/backend/apps/projects/migrations/0009_quickcreatejob.py new file mode 100644 index 0000000..2ccf3a9 --- /dev/null +++ b/core/backend/apps/projects/migrations/0009_quickcreatejob.py @@ -0,0 +1,92 @@ +# Generated by Django 5.1.15 on 2026-08-24 + +import django.db.models.deletion +import uuid +from django.conf import settings +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ("accounts", "0009_team_price_multiplier"), + ("projects", "0008_scripttemplate"), + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ] + + operations = [ + migrations.CreateModel( + name="QuickCreateJob", + fields=[ + ("id", models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False)), + ("created_at", models.DateTimeField(auto_now_add=True)), + ("updated_at", models.DateTimeField(auto_now=True)), + ( + "status", + models.CharField( + choices=[ + ("queued", "Queued"), + ("running", "Running"), + ("succeeded", "Succeeded"), + ("failed", "Failed"), + ("cancelled", "Cancelled"), + ], + default="queued", + max_length=24, + ), + ), + ( + "phase", + models.CharField( + choices=[ + ("product", "Product"), + ("script", "Script"), + ("assets", "Assets"), + ("production", "Storyboard and Video"), + ("complete", "Complete"), + ], + default="product", + max_length=24, + ), + ), + ("progress", models.PositiveSmallIntegerField(default=0)), + ("message", models.CharField(blank=True, max_length=255)), + ("error_message", models.TextField(blank=True)), + ("metadata", models.JSONField(blank=True, default=dict)), + ("completed_at", models.DateTimeField(blank=True, null=True)), + ( + "created_by", + models.ForeignKey( + blank=True, + null=True, + on_delete=django.db.models.deletion.SET_NULL, + related_name="created_%(class)s_set", + to=settings.AUTH_USER_MODEL, + ), + ), + ( + "project", + models.OneToOneField( + on_delete=django.db.models.deletion.CASCADE, + related_name="quick_create_job", + to="projects.project", + ), + ), + ( + "team", + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + related_name="%(class)s_set", + to="accounts.team", + ), + ), + ], + options={ + "ordering": ["-created_at"], + "indexes": [ + models.Index(fields=["team", "status", "-created_at"], name="projects_qu_team_id_a8a924_idx"), + models.Index(fields=["team", "phase"], name="projects_qu_team_id_98380e_idx"), + ], + }, + ), + ] diff --git a/core/backend/apps/projects/models.py b/core/backend/apps/projects/models.py index cea311d..f777dcc 100644 --- a/core/backend/apps/projects/models.py +++ b/core/backend/apps/projects/models.py @@ -70,6 +70,44 @@ class ProjectStage(TimeStampedModel): ordering = ["created_at"] +class QuickCreateJob(TeamOwnedModel): + """“极速成片”自动编排任务。 + + AI 子任务仍落在 AITask;本表只保存跨商品、脚本、资产、故事板、视频和导出的编排状态, + 让页面刷新、浏览器关闭后都能继续查询同一条流水线。 + """ + + class Status(models.TextChoices): + QUEUED = "queued", "Queued" + RUNNING = "running", "Running" + SUCCEEDED = "succeeded", "Succeeded" + FAILED = "failed", "Failed" + CANCELLED = "cancelled", "Cancelled" + + class Phase(models.TextChoices): + PRODUCT = "product", "Product" + SCRIPT = "script", "Script" + ASSETS = "assets", "Assets" + PRODUCTION = "production", "Storyboard and Video" + COMPLETE = "complete", "Complete" + + project = models.OneToOneField(Project, on_delete=models.CASCADE, related_name="quick_create_job") + status = models.CharField(max_length=24, choices=Status.choices, default=Status.QUEUED) + phase = models.CharField(max_length=24, choices=Phase.choices, default=Phase.PRODUCT) + progress = models.PositiveSmallIntegerField(default=0) + message = models.CharField(max_length=255, blank=True) + error_message = models.TextField(blank=True) + metadata = models.JSONField(default=dict, blank=True) + completed_at = models.DateTimeField(null=True, blank=True) + + class Meta: + ordering = ["-created_at"] + indexes = [ + models.Index(fields=["team", "status", "-created_at"], name="projects_qu_team_id_a8a924_idx"), + models.Index(fields=["team", "phase"], name="projects_qu_team_id_98380e_idx"), + ] + + class ScriptVersion(TimeStampedModel): project = models.ForeignKey(Project, on_delete=models.CASCADE, related_name="script_versions") task = models.ForeignKey("ai.AITask", on_delete=models.SET_NULL, null=True, blank=True, related_name="script_versions") @@ -334,4 +372,3 @@ class ExportJob(TimeStampedModel): progress = models.PositiveIntegerField(default=0) error_message = models.TextField(blank=True) metadata = models.JSONField(default=dict, blank=True) - diff --git a/core/backend/apps/projects/serializers.py b/core/backend/apps/projects/serializers.py index 1a5c718..ff0bc4b 100644 --- a/core/backend/apps/projects/serializers.py +++ b/core/backend/apps/projects/serializers.py @@ -10,6 +10,7 @@ from .models import ( ExportJob, Project, ProjectStage, + QuickCreateJob, ScriptSegment, ScriptTemplate, ScriptVersion, @@ -329,6 +330,132 @@ class ExportJobSerializer(serializers.ModelSerializer): read_only_fields = fields +class QuickCreateJobSerializer(serializers.ModelSerializer): + """极速成片轮询响应:只下发用户需要的四阶段进度和最终可播结果。""" + + project_id = serializers.UUIDField(source="project.id", read_only=True) + product_id = serializers.UUIDField(source="project.product_id", read_only=True) + product_name = serializers.CharField(source="project.product.title", read_only=True) + title = serializers.CharField(source="project.name", read_only=True) + product_images = serializers.SerializerMethodField() + phase_index = serializers.SerializerMethodField() + settings = serializers.SerializerMethodField() + result = serializers.SerializerMethodField() + + class Meta: + model = QuickCreateJob + fields = [ + "id", + "project_id", + "product_id", + "product_name", + "title", + "product_images", + "status", + "phase", + "phase_index", + "settings", + "progress", + "message", + "error_message", + "result", + "created_at", + "updated_at", + "completed_at", + ] + read_only_fields = fields + + def get_product_images(self, obj) -> list[dict]: + product = getattr(obj.project, "product", None) + if product is None: + return [] + return [ + { + "asset_id": str(image.asset_id), + "url": _asset_preview_url(image.asset), + } + for image in product.images.all() + ] + + def get_phase_index(self, obj) -> int: + return { + QuickCreateJob.Phase.PRODUCT: 0, + QuickCreateJob.Phase.SCRIPT: 1, + QuickCreateJob.Phase.ASSETS: 2, + QuickCreateJob.Phase.PRODUCTION: 3, + QuickCreateJob.Phase.COMPLETE: 3, + }.get(obj.phase, 0) + + def get_settings(self, obj) -> dict: + wizard = dict((obj.project.metadata or {}).get("wizard") or {}) + return { + "aspect_ratio": str(wizard.get("aspect_ratio") or "9:16"), + "resolution": str(wizard.get("resolution") or "720p").lower(), + "total_duration": int(wizard.get("total_duration") or 15), + "video_model_config_id": str(wizard.get("video_model_config_id") or ""), + "video_model_name": str(wizard.get("video_model_name") or ""), + "video_model_label": str(wizard.get("video_model_label") or ""), + } + + def get_result(self, obj) -> dict | None: + if obj.status != QuickCreateJob.Status.SUCCEEDED: + return None + project = obj.project + settings = self.get_settings(obj) + video_url = _final_video_url(project) + segments = list(project.video_segments.all()) + if not video_url and len(segments) == 1: + version = segments[0].adopted_version + if version is not None and is_playable_video(version.asset): + video_url = _asset_preview_url(version.asset) + first_shot = next( + (shot for shot in project.storyboard_shots.all() if shot.adopted_version_id), + None, + ) + poster_url = _asset_preview_url(first_shot.adopted_version.asset) if first_shot else "" + script = next((version for version in project.script_versions.all() if version.is_adopted), None) + script_meta = (script.metadata or {}) if script is not None else {} + format_label = {"oral": "口播展示", "drama": "短剧演绎", "vlog": "Vlog种草"}.get( + script_meta.get("presentation_format"), "智能推荐" + ) + structure_label = { + "pain": "痛点解决", + "contrast": "前后对比", + "review": "测评验证", + "scene": "场景种草", + }.get(script_meta.get("video_structure"), "智能推荐") + groups = list(project.base_asset_groups.all()) + person = next((group for group in groups if group.kind == BaseAssetGroup.Kind.PERSON and group.adopted_asset_id), None) + scene = next((group for group in groups if group.kind == BaseAssetGroup.Kind.SCENE and group.adopted_asset_id), None) + duration = sum(segment.target_duration_seconds for segment in segments) + video_segments = [] + for segment in sorted(segments, key=lambda item: item.sort_order): + version = segment.adopted_version + clip_url = _asset_preview_url(version.asset) if version is not None and is_playable_video(version.asset) else "" + video_segments.append( + { + "id": str(segment.id), + "sort_order": segment.sort_order, + "duration_seconds": segment.target_duration_seconds or 15, + "video_url": clip_url, + "poster_url": poster_url, + } + ) + return { + "video_url": video_url, + "poster_url": poster_url, + "duration_seconds": duration or 15, + "aspect_ratio": settings["aspect_ratio"], + "resolution": settings["resolution"], + "video_model": settings["video_model_label"] or settings["video_model_name"], + "structure": structure_label, + "presentation": format_label, + "person": ((person.metadata or {}).get("label") or "自动匹配") if person else "自动匹配", + "scene": ((scene.metadata or {}).get("label") or "自动匹配") if scene else "自动匹配", + "video_segments": video_segments, + } + + class ScriptSegmentSerializer(serializers.ModelSerializer): class Meta: model = ScriptSegment @@ -359,13 +486,14 @@ class ProjectListSerializer(serializers.ModelSerializer): video_segment_count = serializers.IntegerField(read_only=True, default=0) # 合成成片地址:项目列表的播放按钮据此直接播成片(没合成过为空 → 退回进流水线) final_video_url = serializers.SerializerMethodField() + quick_create = serializers.SerializerMethodField() class Meta: model = Project fields = [ "id", "name", "product", "product_title", "cover_preview_url", "status", "current_stage", "script_version_count", "video_segment_count", - "final_video_url", + "final_video_url", "quick_create", "is_deleted", "purged_at", "created_at", "updated_at", ] @@ -375,6 +503,9 @@ class ProjectListSerializer(serializers.ModelSerializer): def get_final_video_url(self, obj) -> str: return _final_video_url(obj) + def get_quick_create(self, obj) -> bool: + return bool((obj.metadata or {}).get("quick_create")) + class ProjectSerializer(serializers.ModelSerializer): stages = ProjectStageSerializer(many=True, read_only=True) diff --git a/core/backend/apps/projects/services/export.py b/core/backend/apps/projects/services/export.py index 5a9732f..0fd1a2e 100644 --- a/core/backend/apps/projects/services/export.py +++ b/core/backend/apps/projects/services/export.py @@ -98,27 +98,28 @@ def _wrap_cjk(draw, text: str, font, max_width: int) -> list[str]: return lines[:3] # 最多 3 行,够长截断 -def _render_subtitle_png(text: str, style_key: str, path: Path) -> tuple[int, int]: - """把一条字幕渲染成 1080 宽的透明 PNG(居中,带描边/底框),返回 (w,h)。""" +def _render_subtitle_png(text: str, style_key: str, path: Path, *, canvas_w: int = 1080) -> tuple[int, int]: + """把一条字幕渲染成与导出宽度一致的透明 PNG(居中,带描边/底框)。""" from PIL import Image, ImageDraw - st = SUBTITLE_STYLES.get(style_key) or SUBTITLE_STYLES["plain"] - canvas_w = 1080 - margin_x = 90 + base = SUBTITLE_STYLES.get(style_key) or SUBTITLE_STYLES["plain"] + scale = max(0.4, canvas_w / 1080) + st = {**base, "size": max(24, round(base["size"] * scale)), "stroke_w": max(0, round(base["stroke_w"] * scale))} + margin_x = max(30, round(90 * scale)) font = _load_font(st["size"]) probe = ImageDraw.Draw(Image.new("RGBA", (10, 10))) lines = _wrap_cjk(probe, (text or "").strip().replace("\n", " "), font, canvas_w - 2 * margin_x) - line_h = st["size"] + 16 - pad = 22 + line_h = st["size"] + max(6, round(16 * scale)) + pad = max(8, round(22 * scale)) text_h = line_h * len(lines) canvas_h = text_h + 2 * pad img = Image.new("RGBA", (canvas_w, canvas_h), (0, 0, 0, 0)) draw = ImageDraw.Draw(img) if st["box"]: widest = max((draw.textlength(ln, font=font) for ln in lines), default=0) - box_w = int(widest) + 2 * pad + 24 + box_w = int(widest) + 2 * pad + max(10, round(24 * scale)) x0 = (canvas_w - box_w) // 2 - draw.rounded_rectangle([x0, 0, x0 + box_w, canvas_h], radius=16, fill=st["box"]) + draw.rounded_rectangle([x0, 0, x0 + box_w, canvas_h], radius=max(6, round(16 * scale)), fill=st["box"]) y = pad for ln in lines: w = draw.textlength(ln, font=font) @@ -186,7 +187,8 @@ def _build_export_command(*, n: int, specs: list[dict], starts: list[float], tot transition: str, sub_overlays: list[tuple[str, float, float]], bgm_name: str | None, bgm_volume: float, has_audio: list[bool] | None = None, fps: float = 30.0, - voice_overlays: list[tuple[str, float, float]] | None = None) -> list[str]: + voice_overlays: list[tuple[str, float, float]] | None = None, + output_width: int = 1080, output_height: int = 1920) -> list[str]: has_audio = has_audio or [False] * n voice_overlays = voice_overlays or [] fps_expr = f"{fps:.6g}" @@ -194,8 +196,8 @@ def _build_export_command(*, n: int, specs: list[dict], starts: list[float], tot for i, s in enumerate(specs): parts.append( f"[{i}:v]trim=start={s['ts']:.3f}:end={s['te']:.3f},setpts=PTS-STARTPTS," - "scale=1080:1920:force_original_aspect_ratio=decrease," - f"pad=1080:1920:(ow-iw)/2:(oh-ih)/2,setsar=1,fps={fps_expr},format=yuv420p[v" + str(i) + "]" + f"scale={output_width}:{output_height}:force_original_aspect_ratio=decrease," + f"pad={output_width}:{output_height}:(ow-iw)/2:(oh-ih)/2,setsar=1,fps={fps_expr},format=yuv420p[v" + str(i) + "]" ) xname = XFADE_MAP.get(transition or "none") if xname and n > 1: @@ -213,8 +215,9 @@ def _build_export_command(*, n: int, specs: list[dict], starts: list[float], tot for j, (_png, start, end) in enumerate(sub_overlays): idx = sub_base + j out = "vout" if j == len(sub_overlays) - 1 else f"ov{j}" + bottom_offset = max(40, round(output_height * 150 / 1920)) parts.append( - f"[{vlabel}][{idx}:v]overlay=x=(W-w)/2:y=H-h-150:enable='between(t,{start:.3f},{end:.3f})'[{out}]" + f"[{vlabel}][{idx}:v]overlay=x=(W-w)/2:y=H-h-{bottom_offset}:enable='between(t,{start:.3f},{end:.3f})'[{out}]" ) vlabel = out @@ -331,6 +334,19 @@ def _split_subtitle_text(text: str) -> list[str]: return out +def _timeline_output_size(timeline) -> tuple[int, int]: + """读取时间线输出尺寸;旧项目或异常值安全回落到原来的 1080×1920。""" + try: + width_text, height_text = str(timeline.resolution or "").lower().split("x", 1) + width, height = int(width_text), int(height_text) + except (TypeError, ValueError): + return 1080, 1920 + if not 320 <= width <= 4400 or not 320 <= height <= 4400: + return 1080, 1920 + # H.264 yuv420p 要求偶数尺寸。 + return width - width % 2, height - height % 2 + + def _subtitle_cues(timeline, project, specs, starts, total) -> list[tuple[float, float, str]]: """字幕条目(逐句):优先用 SubtitleTrack.content 里每条 cue 自带的 start_ms—— 先定位到所属片段(输入时间轴=各片段时长累计),再重映射到输出时间轴(xfade 会压缩起点); @@ -424,6 +440,7 @@ def run_export_job(export_job_id: str) -> ExportJob: style_key = str((subtitle_track.style or {}).get("key", "plain")) if subtitle_track else "plain" specs = _clip_specs(clips) + output_width, output_height = _timeline_output_size(timeline) xfade = 0.5 if XFADE_MAP.get(transition) and len(clips) > 1 else 0.0 starts, total = _output_starts(specs, xfade) @@ -451,7 +468,7 @@ def run_export_job(export_job_id: str) -> ExportJob: sub_overlays: list[tuple[str, float, float]] = [] for i, (start, end, text) in enumerate(cues): png = f"sub{i}.png" - _render_subtitle_png(text, style_key, tmp / png) + _render_subtitle_png(text, style_key, tmp / png, canvas_w=output_width) sub_overlays.append((png, start, end)) # 旁白配音(TTS 资产):按 timeline.metadata.voiceover 映射下载,人声轨混在 BGM 之上; @@ -484,6 +501,7 @@ def run_export_job(export_job_id: str) -> ExportJob: n=len(clips), specs=specs, starts=starts, total=total, transition=transition, sub_overlays=sub_overlays, bgm_name=bgm_name, bgm_volume=(bgm_track.volume / 100.0) if bgm_track else 1.0, has_audio=has_audio, fps=output_fps, voice_overlays=voice_overlays, + output_width=output_width, output_height=output_height, ) # 加超时:ffmpeg 卡死(资源不足/被 OOM 杀)时不能让任务永久停在 RUNNING、前端无限转圈; # 超时即按失败收尾,前端能看到「导出失败」而非一直卡。15 分钟足够正常 60s 成片。 @@ -527,4 +545,3 @@ def run_export_job(export_job_id: str) -> ExportJob: project.status = project.Status.COMPLETED project.save(update_fields=["status", "updated_at"]) return export_job - diff --git a/core/backend/apps/projects/services/pipeline.py b/core/backend/apps/projects/services/pipeline.py index 6224f4f..419db56 100644 --- a/core/backend/apps/projects/services/pipeline.py +++ b/core/backend/apps/projects/services/pipeline.py @@ -1,6 +1,8 @@ from dataclasses import dataclass -from apps.projects.models import ProjectStage +from django.utils import timezone + +from apps.projects.models import Project, ProjectStage, ScriptVersion, VideoSegment STAGE_ORDER = [ @@ -35,3 +37,107 @@ def can_enter_stage(current_stage: str, target_stage: str, allow_skip_storyboard return StageTransition(current_stage, target_stage, False, "stage prerequisite is not satisfied") + +def initialize_project_pipeline(project: Project, *, placeholder_segments: int = 4) -> None: + """建立专业创作和极速成片共用的阶段、视频占位数据。幂等,可安全重试。""" + for stage_name in STAGE_ORDER: + ProjectStage.objects.get_or_create(project=project, stage=stage_name) + for index in range(placeholder_segments): + VideoSegment.objects.get_or_create( + project=project, + sort_order=index, + defaults={"target_duration_seconds": 15}, + ) + + +def sync_video_segments_to_script(project: Project, script: ScriptVersion) -> None: + """把视频段数量和时长收口到采用脚本;已生成过的段绝不裁剪或改时长。""" + if not script.is_adopted: + return + script_segments = list(script.segments.order_by("sort_order")) + target = len(script_segments) + segments = list(project.video_segments.order_by("sort_order")) + while len(segments) > target: + tail = segments[-1] + if tail.status == VideoSegment.Status.NOT_STARTED and not tail.versions.exists(): + tail.delete() + segments.pop() + else: + break + + next_order = (segments[-1].sort_order + 1) if segments else 0 + for _ in range(target - len(segments)): + index = len(segments) + seconds = script_segments[index].duration_seconds if index < target else 15 + segments.append( + VideoSegment.objects.create( + project=project, + sort_order=next_order, + target_duration_seconds=seconds, + ) + ) + next_order += 1 + + stale: list[VideoSegment] = [] + for index, video_segment in enumerate(segments): + if index >= target: + break + seconds = script_segments[index].duration_seconds + if not seconds or video_segment.target_duration_seconds == seconds: + continue + if video_segment.status == VideoSegment.Status.SUCCEEDED or video_segment.versions.exists(): + continue + video_segment.target_duration_seconds = seconds + stale.append(video_segment) + if stale: + VideoSegment.objects.bulk_update(stale, ["target_duration_seconds"]) + + +def adopt_script_version(project: Project, script: ScriptVersion) -> None: + """采用脚本并推进到资产阶段,供专业创作按钮和极速编排共同调用。""" + ScriptVersion.objects.filter(project=project).exclude(id=script.id).update(is_adopted=False) + if not script.is_adopted: + script.is_adopted = True + script.save(update_fields=["is_adopted", "updated_at"]) + sync_video_segments_to_script(project, script) + stage, _ = ProjectStage.objects.get_or_create(project=project, stage=ProjectStage.Stage.SCRIPT) + stage.status = ProjectStage.Status.SUCCEEDED + stage.completed_at = timezone.now() + stage.error_message = "" + stage.save(update_fields=["status", "completed_at", "error_message", "updated_at"]) + project.current_stage = ProjectStage.Stage.BASE_ASSETS + project.status = Project.Status.ASSETING + project.failure_reason = "" + project.save(update_fields=["current_stage", "status", "failure_reason", "updated_at"]) + + +def finish_storyboard_stage(project: Project) -> None: + """故事板全部成功后推进到视频阶段,并再次校准片段数量。""" + stage, _ = ProjectStage.objects.get_or_create(project=project, stage=ProjectStage.Stage.STORYBOARD) + stage.status = ProjectStage.Status.SUCCEEDED + stage.completed_at = timezone.now() + stage.error_message = "" + stage.save(update_fields=["status", "completed_at", "error_message", "updated_at"]) + adopted_script = project.script_versions.filter(is_adopted=True).order_by("-created_at").first() + if adopted_script is not None: + sync_video_segments_to_script(project, adopted_script) + project.current_stage = ProjectStage.Stage.VIDEO + project.status = Project.Status.VIDEOING + project.save(update_fields=["current_stage", "status", "updated_at"]) + + +def finish_video_stage(project: Project) -> bool: + """全部片段成功即完成专业创作的视频阶段。""" + segments = list(project.video_segments.values_list("status", "adopted_version_id")) + if not segments or not all(status == VideoSegment.Status.SUCCEEDED and adopted for status, adopted in segments): + return False + stage, _ = ProjectStage.objects.get_or_create(project=project, stage=ProjectStage.Stage.VIDEO) + stage.status = ProjectStage.Status.SUCCEEDED + stage.completed_at = timezone.now() + stage.error_message = "" + stage.save(update_fields=["status", "completed_at", "error_message", "updated_at"]) + project.current_stage = ProjectStage.Stage.VIDEO + project.status = Project.Status.COMPLETED + project.failure_reason = "" + project.save(update_fields=["current_stage", "status", "failure_reason", "updated_at"]) + return True diff --git a/core/backend/apps/projects/services/quick_create.py b/core/backend/apps/projects/services/quick_create.py new file mode 100644 index 0000000..fb82d48 --- /dev/null +++ b/core/backend/apps/projects/services/quick_create.py @@ -0,0 +1,806 @@ +"""“极速成片”自动编排。 + +这不是另一套生成实现:脚本、基础资产、三视图、故事板、视频和合成全部调用专业创作现有服务。 +本模块只负责按状态推进,并把子任务 ID 持久化到 QuickCreateJob,方便刷新后恢复进度。 +""" +from __future__ import annotations + +import json +import logging +import threading +from datetime import datetime, timedelta + +from django.db import connections, transaction +from django.utils import timezone + +from apps.ai.models import AITask, ModelConfig +from apps.ai.script_agent import stream_script_agent +from apps.ai.services import ( + collect_video_review_blockers, + create_export_job, + generate_base_asset, + generate_person_triview, + get_default_model, + poll_storyboard, + submit_storyboard, + submit_video_segment, +) +from apps.assets import assets_client +from apps.assets.models import Asset +from apps.assets.review import poll_team_reviews, submit_asset_for_review +from apps.projects.models import ( + BaseAssetGroup, + ExportJob, + Project, + ProjectStage, + QuickCreateJob, + Timeline, + TimelineClip, + VideoSegment, +) +from apps.projects.services.pipeline import ( + STAGE_ORDER, + adopt_script_version, + finish_storyboard_stage, + finish_video_stage, +) + +logger = logging.getLogger(__name__) + +POLL_DELAY_SECONDS = 10 +SCRIPT_POLL_SECONDS = 5 +SCRIPT_TIMEOUT = timedelta(minutes=4) +SCRIPT_STOLEN_AFTER = timedelta(seconds=25) +STALE_AFTER = timedelta(seconds=12) +_ACTIVE_TASK_STATUSES = { + AITask.Status.CREATED, + AITask.Status.RESERVED, + AITask.Status.SUBMITTED, + AITask.Status.POLLING, + AITask.Status.POSTPROCESSING, +} +_FINISHED_STATUSES = { + QuickCreateJob.Status.SUCCEEDED, + QuickCreateJob.Status.FAILED, + QuickCreateJob.Status.CANCELLED, +} + + +def _is_finished(job: QuickCreateJob) -> bool: + return job.status in _FINISHED_STATUSES + + +def _quick_settings(project: Project) -> dict: + wizard = dict((project.metadata or {}).get("wizard") or {}) + return { + "aspect_ratio": str(wizard.get("aspect_ratio") or "9:16"), + "resolution": str(wizard.get("resolution") or "720p").lower(), + "total_duration": int(wizard.get("total_duration") or 15), + "video_model_config_id": str(wizard.get("video_model_config_id") or ""), + "video_model_name": str(wizard.get("video_model_name") or ""), + "video_model_label": str(wizard.get("video_model_label") or ""), + } + + +def _save_job(job: QuickCreateJob, **changes) -> None: + fields = [] + for field, value in changes.items(): + setattr(job, field, value) + fields.append(field) + if fields: + job.save(update_fields=[*fields, "updated_at"]) + + +def _safe_error(exc: Exception) -> str: + raw = str(exc or "").strip() + lower = raw.lower() + if "insufficient credit" in lower or "额度不足" in raw: + return "可用积分不足,极速成片已暂停" + if "no active" in lower or "not configured" in lower or "没有可用" in raw: + return "当前缺少可用的生成模型,请联系管理员配置" + if "review" in lower or "审核" in raw: + return "生成素材未通过审核,请进入专业模式调整后重试" + return "极速成片暂未完成,请稍后重试或进入专业模式查看" + + +def fail_quick_create(job: QuickCreateJob, message: str, *, internal_error: str = "") -> None: + job.refresh_from_db(fields=["status", "metadata"]) + if job.status in {QuickCreateJob.Status.SUCCEEDED, QuickCreateJob.Status.CANCELLED}: + return + public_message = (message or "极速成片暂未完成,请稍后重试").strip()[:500] + metadata = dict(job.metadata or {}) + if internal_error: + metadata["internal_error"] = internal_error[:2000] + _save_job( + job, + status=QuickCreateJob.Status.FAILED, + error_message=public_message, + message=public_message, + metadata=metadata, + ) + project = job.project + if project.status != Project.Status.COMPLETED: + project.status = Project.Status.FAILED + project.failure_reason = public_message + project.save(update_fields=["status", "failure_reason", "updated_at"]) + if project.current_stage in STAGE_ORDER: + stage, _ = ProjectStage.objects.get_or_create(project=project, stage=project.current_stage) + stage.status = ProjectStage.Status.FAILED + stage.error_message = public_message + stage.save(update_fields=["status", "error_message", "updated_at"]) + + +def cancel_quick_create(job: QuickCreateJob) -> QuickCreateJob: + """用户主动停掉进行中的极速成片;已完成的任务保持原样。""" + if job.status == QuickCreateJob.Status.SUCCEEDED: + return job + if job.status in {QuickCreateJob.Status.FAILED, QuickCreateJob.Status.CANCELLED}: + return job + public_message = "已取消本次生成" + metadata = dict(job.metadata or {}) + metadata["cancelled"] = True + _save_job( + job, + status=QuickCreateJob.Status.CANCELLED, + error_message=public_message, + message=public_message, + metadata=metadata, + completed_at=timezone.now(), + ) + project = job.project + project.status = Project.Status.FAILED + project.failure_reason = public_message + project.save(update_fields=["status", "failure_reason", "updated_at"]) + if project.current_stage in STAGE_ORDER: + stage, _ = ProjectStage.objects.get_or_create(project=project, stage=project.current_stage) + if stage.status not in {ProjectStage.Status.SUCCEEDED, ProjectStage.Status.FAILED}: + stage.status = ProjectStage.Status.FAILED + stage.error_message = public_message + stage.save(update_fields=["status", "error_message", "updated_at"]) + return job + + +def _consume_script_agent(job: QuickCreateJob) -> None: + project = job.project + settings = _quick_settings(project) + user = job.created_by or project.created_by + if user is None: + raise ValueError("极速成片任务缺少创建人") + model_config = get_default_model(ModelConfig.Capability.TEXT) + if model_config is None: + raise ValueError("no active text model configured") + + error_detail = "" + stream = stream_script_agent( + project=project, + user=user, + model_config=model_config, + mode="auto", + user_prompt=( + "请根据商品名称与商品参考图信息,自动推荐最适合的" + f"{settings['total_duration']}秒、{settings['aspect_ratio']}画幅带货方案。" + ), + aspect_ratio=settings["aspect_ratio"], + total_duration=settings["total_duration"], + presentation_format="oral", + video_structure="pain", + entry_source="ai", + persona="reviewer", + ) + for frame in stream: + job.refresh_from_db(fields=["status"]) + if _is_finished(job): + return + if not frame.startswith("data:"): + continue + try: + event = json.loads(frame[5:].strip()) + except (TypeError, ValueError): + continue + if event.get("type") == "error": + error_detail = str(event.get("detail") or "脚本生成失败") + if error_detail: + raise ValueError(error_detail) + + script = project.script_versions.order_by("-created_at").first() + if script is None or not script.segments.exists(): + raise ValueError("脚本生成后没有可用分镜") + adopt_script_version(project, script) + + +def consume_quick_script(job_id: str) -> None: + """Celery 专用:跑完专业创作同一套脚本 agent,再把编排推去资产阶段。""" + from apps.projects.tasks import advance_quick_create_task + + job = QuickCreateJob.objects.select_related("project__product", "created_by", "team").get(id=job_id) + if _is_finished(job): + return + adopted = job.project.script_versions.filter(is_adopted=True).first() + if adopted is not None and adopted.segments.exists(): + advance_quick_create_task.apply_async(args=[str(job.id)], queue="airshelf.quick") + return + try: + _consume_script_agent(job) + except Exception as exc: # noqa: BLE001 — 脚本失败必须落终态 + logger.exception("quick create script failed for job %s", job.id) + fail_quick_create(job, _safe_error(exc), internal_error=str(exc)) + return + job.refresh_from_db(fields=["status"]) + if _is_finished(job): + return + advance_quick_create_task.apply_async(args=[str(job.id)], queue="airshelf.quick") + + +def _adopted_script(project: Project): + script = project.script_versions.filter(is_adopted=True).order_by("-created_at").first() + if script is not None and script.segments.exists(): + return script + return None + + +def _parse_iso(value) -> datetime | None: + if not value: + return None + try: + parsed = datetime.fromisoformat(str(value)) + except (TypeError, ValueError): + return None + if timezone.is_naive(parsed): + parsed = timezone.make_aware(parsed) + return parsed + + +def _advance_product(job: QuickCreateJob) -> int: + """商品已在提交接口建好;这里只补卖点并切到脚本阶段,让页面先看到「识别商品」。""" + from apps.products.models import ProductSellingPoint + + product = job.project.product + if product is not None and not product.selling_points.exists(): + ProductSellingPoint.objects.create( + product=product, + title=product.title, + detail="由极速成片根据商品名称自动填写", + sort_order=0, + ) + _save_job( + job, + status=QuickCreateJob.Status.RUNNING, + phase=QuickCreateJob.Phase.SCRIPT, + progress=18, + message="已识别商品与卖点,正在推荐脚本方向", + ) + return 1 + + +def _advance_script(job: QuickCreateJob) -> int | None: + if _adopted_script(job.project) is not None: + _save_job(job, phase=QuickCreateJob.Phase.ASSETS, progress=48, message="脚本已生成,正在匹配模特与场景") + return 1 + + metadata = dict(job.metadata or {}) + if not metadata.get("script_started"): + from apps.projects.tasks import run_quick_script_task + + metadata["script_started"] = True + metadata["script_started_at"] = timezone.now().isoformat() + _save_job( + job, + metadata=metadata, + progress=28, + message="正在根据商品名称与图片生成带货脚本", + ) + run_quick_script_task.apply_async(args=[str(job.id)], queue="airshelf.quick") + return SCRIPT_POLL_SECONDS + + failed = ( + AITask.objects.filter( + project=job.project, + task_type=AITask.Type.SCRIPT_GENERATION, + status=AITask.Status.FAILED, + ) + .order_by("-created_at") + .first() + ) + if failed is not None: + fail_quick_create(job, _task_public_error(failed), internal_error=failed.error_message) + return None + + started_at = _parse_iso(metadata.get("script_started_at")) + if started_at and timezone.now() - started_at > SCRIPT_TIMEOUT: + fail_quick_create(job, "脚本生成超时,请稍后重试或进入专业模式查看") + return None + + elapsed = int((timezone.now() - started_at).total_seconds()) if started_at else 0 + _save_job(job, progress=min(44, 28 + elapsed // 8), message="正在生成分镜脚本…") + return SCRIPT_POLL_SECONDS + + +def _ensure_fallback_entities(project: Project) -> list[dict]: + """短脚本偶尔只提到商品;极速模式仍补齐一名模特和一个空镜场景,保证自动链路可走完。""" + metadata = dict(project.metadata or {}) + entities = [dict(item) for item in (metadata.get("script_entities") or []) if isinstance(item, dict)] + types = {str(item.get("type") or "") for item in entities} + added_ids: list[str] = [] + if "character" not in types: + entities.append( + { + "id": "quick_character_1", + "type": "character", + "name": "推荐模特", + "visual_prompt": f"专业电商测评模特,亲和自然,适合展示{project.product.title}", + "ref_index": 1, + } + ) + added_ids.append("quick_character_1") + if "scene" not in types: + settings = _quick_settings(project) + entities.append( + { + "id": "quick_scene_1", + "type": "scene", + "name": "推荐场景", + "visual_prompt": ( + f"明亮干净的电商产品展示空间,适合突出{project.product.title}," + f"{settings['aspect_ratio']}构图" + ), + "ref_index": 2, + } + ) + added_ids.append("quick_scene_1") + if added_ids: + metadata["script_entities"] = entities + metadata["cast"] = [item["name"] for item in entities if item.get("type") == "character"] + metadata["scenes"] = [item["name"] for item in entities if item.get("type") == "scene"] + metadata["cast_prompts"] = { + item["name"]: item.get("visual_prompt", "") for item in entities if item.get("type") == "character" + } + metadata["scene_prompts"] = { + item["name"]: item.get("visual_prompt", "") for item in entities if item.get("type") == "scene" + } + project.metadata = metadata + project.save(update_fields=["metadata", "updated_at"]) + script = project.script_versions.filter(is_adopted=True).order_by("-created_at").first() + if script is not None: + for segment in script.segments.all(): + refs = list(segment.entity_refs or []) + segment.entity_refs = [*refs, *(item for item in added_ids if item not in refs)] + segment.save(update_fields=["entity_refs", "updated_at"]) + return entities + + +def _start_base_assets(job: QuickCreateJob) -> None: + project = job.project + user = job.created_by or project.created_by + if user is None: + raise ValueError("极速成片任务缺少创建人") + with transaction.atomic(): + job = QuickCreateJob.objects.select_for_update().select_related("project").get(id=job.id) + metadata = dict(job.metadata or {}) + if metadata.get("base_asset_task_ids") or metadata.get("assets_started"): + return + metadata["assets_started"] = True + job.metadata = metadata + job.save(update_fields=["metadata", "updated_at"]) + entities = _ensure_fallback_entities(job.project) + specs = [ + (BaseAssetGroup.Kind.PRODUCT, job.project.product.title, job.project.product.title), + *[ + (BaseAssetGroup.Kind.PERSON, str(item.get("visual_prompt") or item.get("name") or "电商模特"), str(item.get("name") or "推荐模特")) + for item in entities + if item.get("type") == "character" + ], + *[ + (BaseAssetGroup.Kind.SCENE, str(item.get("visual_prompt") or item.get("name") or "电商场景"), str(item.get("name") or "推荐场景")) + for item in entities + if item.get("type") == "scene" + ], + ] + task_ids = [] + for kind, prompt, label in specs: + task = generate_base_asset(project=job.project, user=user, kind=kind, prompt=prompt, label=label) + task_ids.append(str(task.id)) + metadata = dict(job.metadata or {}) + metadata["base_asset_task_ids"] = task_ids + _save_job(job, metadata=metadata, message="正在生成商品、模特与场景资产", progress=52) + stage, _ = ProjectStage.objects.get_or_create(project=job.project, stage=ProjectStage.Stage.BASE_ASSETS) + stage.status = ProjectStage.Status.RUNNING + stage.save(update_fields=["status", "updated_at"]) + + +def _task_failure(tasks) -> AITask | None: + return next( + ( + task + for task in tasks + if task.status == AITask.Status.FAILED + or task.status not in _ACTIVE_TASK_STATUSES | {AITask.Status.SUCCEEDED} + ), + None, + ) + + +def _task_public_error(task: AITask) -> str: + from apps.ai.generation_errors import public_error_for_task + + public_error = public_error_for_task(task) + return public_error.fallback_message if public_error else "生成任务失败,请稍后重试" + + +def _advance_assets(job: QuickCreateJob) -> int | None: + metadata = dict(job.metadata or {}) + base_ids = metadata.get("base_asset_task_ids") or [] + if not base_ids: + _start_base_assets(job) + return POLL_DELAY_SECONDS + tasks = list(AITask.objects.filter(project=job.project, id__in=base_ids)) + failed = _task_failure(tasks) + if failed is not None: + fail_quick_create(job, _task_public_error(failed), internal_error=failed.error_message) + return None + if len(tasks) < len(base_ids) or any(task.status in _ACTIVE_TASK_STATUSES for task in tasks): + done = sum(task.status == AITask.Status.SUCCEEDED for task in tasks) + _save_job(job, progress=min(64, 52 + done * 4), message="正在匹配并生成模特与场景") + return POLL_DELAY_SECONDS + + triview_ids = metadata.get("triview_task_ids") + if triview_ids is None: + person_groups = list( + job.project.base_asset_groups.filter( + kind=BaseAssetGroup.Kind.PERSON, + task_id__in=base_ids, + adopted_asset__isnull=False, + ).select_related("adopted_asset") + ) + triview_ids = [ + str(generate_person_triview(project=job.project, user=job.created_by or job.project.created_by, portrait_asset=group.adopted_asset).id) + for group in person_groups + ] + metadata["triview_task_ids"] = triview_ids + _save_job(job, metadata=metadata, progress=65, message="正在为推荐模特生成三视图") + return POLL_DELAY_SECONDS + + triview_tasks = list(AITask.objects.filter(project=job.project, id__in=triview_ids)) + failed = _task_failure(triview_tasks) + if failed is not None: + fail_quick_create(job, _task_public_error(failed), internal_error=failed.error_message) + return None + if len(triview_tasks) < len(triview_ids) or any(task.status in _ACTIVE_TASK_STATUSES for task in triview_tasks): + _save_job(job, progress=68, message="正在为推荐模特生成三视图") + return POLL_DELAY_SECONDS + + for group in job.project.base_asset_groups.filter(adopted_asset__isnull=False): + if group.kind != BaseAssetGroup.Kind.PRODUCT: + group_meta = dict(group.metadata or {}) + group_meta["adopt"] = "adopted" + group.metadata = group_meta + group.save(update_fields=["metadata", "updated_at"]) + stage, _ = ProjectStage.objects.get_or_create(project=job.project, stage=ProjectStage.Stage.BASE_ASSETS) + stage.status = ProjectStage.Status.SUCCEEDED + stage.completed_at = timezone.now() + stage.error_message = "" + stage.save(update_fields=["status", "completed_at", "error_message", "updated_at"]) + job.project.current_stage = ProjectStage.Stage.STORYBOARD + job.project.status = Project.Status.STORYBOARDING + job.project.save(update_fields=["current_stage", "status", "updated_at"]) + _save_job( + job, + phase=QuickCreateJob.Phase.PRODUCTION, + progress=72, + message="正在生成故事板与镜头画面", + ) + return 1 + + +def _reviews_ready(job: QuickCreateJob) -> bool | None: + """True=可出视频,False=继续等,None=审核失败且任务已终止。""" + if not assets_client.is_enabled(): + return True + poll_team_reviews(job.team) + blockers = collect_video_review_blockers(job.project) + if not blockers: + return True + if any(item.get("review_status") == "failed" for item in blockers): + fail_quick_create(job, "生成素材未通过审核,请进入专业模式调整后重试") + return None + metadata = dict(job.metadata or {}) + wait_started = metadata.get("review_wait_started") + if not wait_started: + metadata["review_wait_started"] = timezone.now().isoformat() + _save_job(job, metadata=metadata) + else: + try: + started_at = datetime.fromisoformat(str(wait_started)) + if timezone.is_naive(started_at): + started_at = timezone.make_aware(started_at) + if timezone.now() - started_at > timedelta(minutes=20): + fail_quick_create(job, "素材质量检查等待超时,请进入专业模式查看") + return None + except (TypeError, ValueError): + metadata["review_wait_started"] = timezone.now().isoformat() + _save_job(job, metadata=metadata) + for item in blockers: + if not item.get("review_status") and item.get("asset_id"): + asset = Asset.objects.filter(team=job.team, id=item["asset_id"]).first() + if asset is not None: + submit_asset_for_review(asset) + _save_job(job, progress=82, message="故事板已完成,正在进行视频素材质量检查") + return False + + +def _start_videos(job: QuickCreateJob) -> None: + from apps.projects.tasks import poll_video_segment_task + + settings = _quick_settings(job.project) + for segment in job.project.video_segments.order_by("sort_order"): + if segment.status in {VideoSegment.Status.RUNNING, VideoSegment.Status.QUEUED, VideoSegment.Status.SUCCEEDED}: + continue + submit_video_segment( + video_segment=segment, + user=job.created_by or job.project.created_by, + prompt="极速成片自动生成,严格遵循本镜故事板与脚本。", + model_config_id=settings["video_model_config_id"] or None, + aspect_ratio=settings["aspect_ratio"], + resolution=settings["resolution"], + ) + poll_video_segment_task.apply_async(args=[str(segment.id)], countdown=30) + metadata = dict(job.metadata or {}) + metadata["video_started"] = True + _save_job( + job, + metadata=metadata, + progress=86, + message=f"正在生成{settings['total_duration']}秒 {settings['aspect_ratio']} 视频", + ) + + +def _start_export(job: QuickCreateJob) -> None: + from apps.projects.tasks import run_export_job_task + from apps.ai.video_pricing import get_resolution + + project = job.project + settings = _quick_settings(project) + total_duration = sum(project.video_segments.values_list("target_duration_seconds", flat=True)) or 15 + width, height = get_resolution(settings["aspect_ratio"], settings["resolution"]) + with transaction.atomic(): + timeline, _ = Timeline.objects.get_or_create( + project=project, + defaults={ + "name": f"{project.name} Timeline", + "duration_seconds": total_duration, + "aspect_ratio": settings["aspect_ratio"], + "resolution": f"{width}x{height}", + }, + ) + timeline.duration_seconds = total_duration + timeline.aspect_ratio = settings["aspect_ratio"] + timeline.resolution = f"{width}x{height}" + timeline.save(update_fields=["duration_seconds", "aspect_ratio", "resolution", "updated_at"]) + timeline.clips.all().delete() + start_ms = 0 + for segment in project.video_segments.select_related("adopted_version__asset").order_by("sort_order"): + TimelineClip.objects.create( + timeline=timeline, + asset=segment.adopted_version.asset, + sort_order=segment.sort_order, + start_ms=start_ms, + duration_ms=segment.target_duration_seconds * 1000, + ) + start_ms += segment.target_duration_seconds * 1000 + export_job = create_export_job(timeline=timeline, user=job.created_by or project.created_by) + run_export_job_task.delay(str(export_job.id)) + metadata = dict(job.metadata or {}) + metadata["export_job_id"] = str(export_job.id) + _save_job(job, metadata=metadata, progress=96, message="各镜头已生成,正在合成为完整视频") + + +def _videos_ready(job: QuickCreateJob) -> bool: + segments = list(job.project.video_segments.order_by("sort_order")) + if not segments: + return False + return all( + segment.status == VideoSegment.Status.SUCCEEDED and segment.adopted_version_id + for segment in segments + ) + + +def _can_complete(job: QuickCreateJob) -> bool: + if not _videos_ready(job): + return False + segments = list(job.project.video_segments.all()) + if len(segments) <= 1: + return True + export_job_id = (job.metadata or {}).get("export_job_id") + if not export_job_id: + return False + export_job = ExportJob.objects.filter(id=export_job_id, timeline__project=job.project).first() + return export_job is not None and export_job.status == ExportJob.Status.SUCCEEDED + + +def _complete(job: QuickCreateJob) -> None: + _save_job( + job, + status=QuickCreateJob.Status.SUCCEEDED, + phase=QuickCreateJob.Phase.COMPLETE, + progress=100, + message="视频已生成", + error_message="", + completed_at=timezone.now(), + ) + + +def _advance_production(job: QuickCreateJob) -> int | None: + metadata = dict(job.metadata or {}) + if not metadata.get("storyboard_started"): + submit_storyboard(project=job.project, user=job.created_by or job.project.created_by, prompt="") + stage, _ = ProjectStage.objects.get_or_create(project=job.project, stage=ProjectStage.Stage.STORYBOARD) + stage.status = ProjectStage.Status.RUNNING + stage.save(update_fields=["status", "updated_at"]) + metadata["storyboard_started"] = True + _save_job(job, metadata=metadata, progress=74, message="正在生成故事板与镜头画面") + + if job.project.current_stage != ProjectStage.Stage.VIDEO: + result = poll_storyboard(project=job.project, user=job.created_by or job.project.created_by) + if result.get("status") == "failed": + fail_quick_create(job, str(result.get("error") or "故事板生成失败")) + return None + if result.get("status") != "succeeded": + total = max(1, int(result.get("total") or 1)) + done = int(result.get("done") or 0) + _save_job(job, progress=min(80, 74 + round(done / total * 6)), message=f"正在生成故事板({done}/{total})") + return POLL_DELAY_SECONDS + finish_storyboard_stage(job.project) + + review_state = _reviews_ready(job) + if review_state is None: + return None + if not review_state: + return POLL_DELAY_SECONDS + + if not (job.metadata or {}).get("video_started"): + _start_videos(job) + return POLL_DELAY_SECONDS + + segments = list(job.project.video_segments.order_by("sort_order")) + failed = next((segment for segment in segments if segment.status == VideoSegment.Status.FAILED), None) + if failed is not None: + fail_quick_create(job, failed.error_message or f"第{failed.sort_order + 1}段视频生成失败") + return None + completed = sum( + 1 + for segment in segments + if segment.status == VideoSegment.Status.SUCCEEDED and segment.adopted_version_id + ) + if completed < len(segments): + _save_job(job, progress=min(94, 86 + completed * 3), message=f"正在生成视频({completed}/{len(segments)})") + return POLL_DELAY_SECONDS + + finish_video_stage(job.project) + if len(segments) == 1: + _complete(job) + return None + + export_job_id = (job.metadata or {}).get("export_job_id") + if not export_job_id: + _start_export(job) + return POLL_DELAY_SECONDS + export_job = ExportJob.objects.filter(id=export_job_id, timeline__project=job.project).first() + if export_job is None: + raise ValueError("视频合成任务不存在") + if export_job.status == ExportJob.Status.FAILED: + fail_quick_create(job, "视频片段已生成,但自动合成失败,请进入专业模式查看", internal_error=export_job.error_message) + return None + if export_job.status != ExportJob.Status.SUCCEEDED: + _save_job(job, progress=max(96, min(99, int(export_job.progress or 0))), message="正在合成为完整视频") + return POLL_DELAY_SECONDS + _complete(job) + return None + + +_local_script_jobs: set[str] = set() + + +def _script_task_started(job: QuickCreateJob) -> bool: + return job.project.ai_tasks.filter(task_type=AITask.Type.SCRIPT_GENERATION).exists() + + +def _claim_next_advance(job_id: str, delay_seconds: int) -> bool: + """同一任务同一时刻只允许一枚后续推进,避免轮询把队列打爆。""" + with transaction.atomic(): + job = QuickCreateJob.objects.select_for_update().get(id=job_id) + metadata = dict(job.metadata or {}) + eta = _parse_iso(metadata.get("next_advance_at")) + if eta and eta > timezone.now(): + return False + metadata["next_advance_at"] = (timezone.now() + timedelta(seconds=max(1, delay_seconds))).isoformat() + job.metadata = metadata + job.save(update_fields=["metadata", "updated_at"]) + return True + + +def _run_quick_script_in_thread(job_id: str) -> None: + """共享 Redis 上的旧 worker 会把未注册任务直接丢掉;本机线程兜底,避免页面永久转圈。""" + if job_id in _local_script_jobs: + return + _local_script_jobs.add(job_id) + + def _worker() -> None: + try: + consume_quick_script(job_id) + except Exception as exc: # noqa: BLE001 — 线程失败必须落终态 + job = QuickCreateJob.objects.select_related("project").filter(id=job_id).first() + if job is not None and not _is_finished(job): + fail_quick_create(job, _safe_error(exc), internal_error=str(exc)) + finally: + _local_script_jobs.discard(job_id) + connections.close_all() + + threading.Thread(target=_worker, daemon=True, name=f"quick-script-{job_id[:8]}").start() + + +def recover_quick_create(job: QuickCreateJob) -> None: + """前端轮询时把卡住的编排拉起来:超时落失败,被旧 worker 丢掉的脚本改走本机线程。""" + job.refresh_from_db() + if job.status == QuickCreateJob.Status.FAILED and _can_complete(job): + _complete(job) + return + if _is_finished(job): + return + job_id = str(job.id) + metadata = dict(job.metadata or {}) + if job.phase == QuickCreateJob.Phase.SCRIPT and metadata.get("script_started"): + started_at = _parse_iso(metadata.get("script_started_at")) + adopted = _adopted_script(job.project) + local_running = job_id in _local_script_jobs + timed_out = bool(started_at and timezone.now() - started_at > SCRIPT_TIMEOUT) + stolen = bool( + started_at + and timezone.now() - started_at > SCRIPT_STOLEN_AFTER + and adopted is None + and not local_running + ) + if stolen: + metadata["script_local"] = True + metadata["script_started_at"] = timezone.now().isoformat() + _save_job(job, metadata=metadata, message="正在继续生成分镜脚本…") + _run_quick_script_in_thread(job_id) + return + if timed_out and adopted is None and not local_running: + fail_quick_create(job, "脚本生成超时,请稍后重试或进入专业模式查看") + return + if timezone.now() - job.updated_at > STALE_AFTER and _claim_next_advance(str(job.id), SCRIPT_POLL_SECONDS): + from apps.projects.tasks import advance_quick_create_task + + try: + advance_quick_create_task.apply_async(args=[str(job.id)], queue="airshelf.quick") + except Exception: # noqa: BLE001 — 队列不可用时就地推进一步,避免永久 loading + advance_quick_create(str(job.id)) + + +def advance_quick_create(job_id: str) -> int | None: + """推进一个状态并返回下次轮询秒数;返回 None 表示终态。 + + 一次只做一段:识别商品 → 脚本(独立任务) → 资产 → 故事板/视频/合成。 + 脚本不再在编排任务里同步吃完整条 SSE,避免页面一直停在「推荐脚本方向」。 + """ + job = QuickCreateJob.objects.select_related("project__product", "created_by", "team").get(id=job_id) + if _is_finished(job): + return None + try: + if job.status == QuickCreateJob.Status.QUEUED: + _save_job(job, status=QuickCreateJob.Status.RUNNING, progress=8, message="正在识别商品图片与卖点") + if job.phase == QuickCreateJob.Phase.PRODUCT: + return _advance_product(job) + if job.phase == QuickCreateJob.Phase.SCRIPT: + return _advance_script(job) + if job.phase == QuickCreateJob.Phase.ASSETS: + return _advance_assets(job) + if job.phase == QuickCreateJob.Phase.PRODUCTION: + return _advance_production(job) + return None + except Exception as exc: # noqa: BLE001 — 编排失败必须落可恢复终态,不能留下永久 loading + logger.exception("quick create job %s failed", job.id) + if _can_complete(job): + _complete(job) + return None + fail_quick_create(job, _safe_error(exc), internal_error=str(exc)) + return None diff --git a/core/backend/apps/projects/tasks.py b/core/backend/apps/projects/tasks.py index ac05202..c5a6f3e 100644 --- a/core/backend/apps/projects/tasks.py +++ b/core/backend/apps/projects/tasks.py @@ -42,3 +42,56 @@ def run_export_job_task(self, export_job_id: str) -> str: export_job.save(update_fields=["status", "error_message", "updated_at"]) raise return export_job_id + + +QUICK_CREATE_QUEUE = "airshelf.quick" + + +@app.task(bind=True, max_retries=0, soft_time_limit=240, time_limit=270, queue=QUICK_CREATE_QUEUE) +def run_quick_script_task(self, quick_job_id: str) -> str: + """脚本生成单独跑,避免把整条极速成片编排堵在一次 SSE 消费里。""" + from celery.exceptions import SoftTimeLimitExceeded + + from apps.projects.models import QuickCreateJob + from apps.projects.services.quick_create import consume_quick_script, fail_quick_create + + try: + consume_quick_script(quick_job_id) + except SoftTimeLimitExceeded: + job = QuickCreateJob.objects.select_related("project").filter(id=quick_job_id).first() + if job is not None and job.status not in { + QuickCreateJob.Status.SUCCEEDED, + QuickCreateJob.Status.FAILED, + QuickCreateJob.Status.CANCELLED, + }: + fail_quick_create(job, "脚本生成超时,请稍后重试或进入专业模式查看") + raise + return quick_job_id + + +@app.task(bind=True, max_retries=0, queue=QUICK_CREATE_QUEUE) +def advance_quick_create_task(self, quick_job_id: str) -> str: + """一次只推进一个可重入状态,等待型阶段通过重新入队轮询,不占 worker 睡眠。""" + from apps.projects.services.quick_create import advance_quick_create + + next_delay = advance_quick_create(quick_job_id) + if next_delay is not None: + from apps.projects.services.quick_create import _claim_next_advance + + if not _claim_next_advance(quick_job_id, int(next_delay)): + return quick_job_id + try: + advance_quick_create_task.apply_async( + args=[quick_job_id], + countdown=max(1, int(next_delay)), + queue=QUICK_CREATE_QUEUE, + ) + except Exception as exc: # noqa: BLE001 — 重排失败不能留下永久“生成中” + from apps.projects.models import QuickCreateJob + from apps.projects.services.quick_create import fail_quick_create + + job = QuickCreateJob.objects.select_related("project").filter(id=quick_job_id).first() + if job is not None: + fail_quick_create(job, "生成队列暂时中断,请稍后重试", internal_error=str(exc)) + raise + return quick_job_id diff --git a/core/backend/apps/projects/test_quick_create.py b/core/backend/apps/projects/test_quick_create.py new file mode 100644 index 0000000..d7059aa --- /dev/null +++ b/core/backend/apps/projects/test_quick_create.py @@ -0,0 +1,490 @@ +from datetime import timedelta +from unittest.mock import patch +from types import SimpleNamespace +import uuid + +from django.core.files.uploadedfile import SimpleUploadedFile +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.ai.models import ModelConfig, ModelProvider +from apps.assets.models import Asset, AssetFile +from apps.products.models import Product, ProductImage +from apps.projects.models import Project, ProjectStage, QuickCreateJob, ScriptSegment, ScriptVersion, VideoSegment, VideoSegmentVersion +from apps.projects.serializers import ProjectListSerializer, QuickCreateJobSerializer +from apps.projects.services.pipeline import initialize_project_pipeline +from apps.projects.services.quick_create import ( + _start_videos, + advance_quick_create, + cancel_quick_create, + recover_quick_create, +) + + +class QuickCreateApiTests(TestCase): + def setUp(self): + self.user = User.objects.create_user(username="quick-owner", password="pass") + self.team = Team.objects.create(name="Quick Team", owner=self.user) + TeamMember.objects.create(team=self.team, user=self.user, role=TeamMember.Role.OWNER) + self.client = APIClient() + self.client.force_authenticate(self.user) + + def _uploaded_asset(self, **kwargs): + upload = kwargs["upload"] + return Asset.objects.create( + team=self.team, + created_by=self.user, + name=upload.name, + asset_type=Asset.Type.IMAGE, + source=Asset.Source.UPLOAD, + category=Asset.Category.PRODUCT_IMAGE, + ) + + @patch("apps.projects.views.get_default_model", return_value=object()) + @patch("apps.projects.views.advance_quick_create_task.apply_async") + @patch("apps.projects.views.require_worker_task") + @patch("apps.projects.views._store_uploaded_asset") + def test_submit_creates_product_project_and_persistent_job(self, store_asset, require_worker_task, enqueue, get_model): + store_asset.side_effect = self._uploaded_asset + video_model_id = uuid.uuid4() + get_model.side_effect = [ + object(), + object(), + SimpleNamespace( + id=video_model_id, + name="doubao-seedance-2-0-fast-260128", + display_name="Seedance 2.0 Fast", + metadata={"capabilities": {"resolutions": ["480p", "720p"], "aspect_ratios": ["9:16"], "durations": [15]}}, + ), + ] + response = self.client.post( + "/api/projects/quick-create/", + { + "name": "轻醒咖啡", + "images": [ + SimpleUploadedFile("front.png", b"png-one", content_type="image/png"), + SimpleUploadedFile("detail.webp", b"webp-two", content_type="image/webp"), + ], + "aspect_ratio": "9:16", + "resolution": "720p", + "total_duration": "30", + }, + format="multipart", + ) + + self.assertEqual(response.status_code, 202) + product = Product.objects.get(title="轻醒咖啡") + self.assertEqual(product.images.count(), 2) + self.assertEqual(product.cover_asset_id, product.images.order_by("sort_order").first().asset_id) + project = Project.objects.get(product=product) + self.assertTrue(project.metadata["quick_create"]) + self.assertEqual(project.metadata["wizard"]["total_duration"], 30) + self.assertEqual(project.metadata["wizard"]["resolution"], "720p") + self.assertEqual(project.metadata["wizard"]["video_model_config_id"], str(video_model_id)) + self.assertEqual(project.stages.count(), 5) + self.assertEqual(project.video_segments.count(), 2) + self.assertTrue(product.selling_points.exists()) + job = QuickCreateJob.objects.get(project=project) + self.assertEqual(str(job.id), response.data["id"]) + enqueue.assert_called_once() + self.assertEqual(enqueue.call_args.kwargs["args"], [str(job.id)]) + self.assertEqual(enqueue.call_args.kwargs["queue"], "airshelf.quick") + require_worker_task.assert_called_once_with("apps.projects.tasks.advance_quick_create_task") + self.assertEqual(get_model.call_count, 3) + self.assertEqual(len(response.data["product_images"]), 2) + self.assertEqual( + {item["asset_id"] for item in response.data["product_images"]}, + {str(image.asset_id) for image in product.images.order_by("sort_order")}, + ) + + @patch("apps.projects.views.get_default_model", return_value=object()) + @patch("apps.projects.views.advance_quick_create_task.apply_async") + @patch("apps.projects.views.require_worker_task") + @patch("apps.projects.views._store_uploaded_asset") + def test_submit_reuses_source_product_images(self, store_asset, require_worker_task, enqueue, get_model): + source_asset = Asset.objects.create( + team=self.team, + created_by=self.user, + name="已有主图.png", + asset_type=Asset.Type.IMAGE, + source=Asset.Source.UPLOAD, + category=Asset.Category.PRODUCT_IMAGE, + ) + source = Product.objects.create(team=self.team, created_by=self.user, title="旧商品", cover_asset=source_asset) + ProductImage.objects.create(product=source, asset=source_asset, sort_order=0, is_primary=True) + get_model.side_effect = [object(), object(), SimpleNamespace(id=uuid.uuid4(), name="seedance", display_name="Seedance", metadata={})] + + response = self.client.post( + "/api/projects/quick-create/", + { + "name": "复用商品图", + "source_product_id": str(source.id), + "aspect_ratio": "9:16", + "resolution": "720p", + "total_duration": "15", + }, + format="multipart", + ) + + self.assertEqual(response.status_code, 202) + store_asset.assert_not_called() + product = Product.objects.get(title="复用商品图") + self.assertEqual(product.images.count(), 1) + self.assertEqual(product.images.first().asset_id, source_asset.id) + self.assertEqual(response.data["product_images"][0]["asset_id"], str(source_asset.id)) + + @patch("apps.projects.views.require_worker_task") + def test_submit_rejects_foreign_source_product_images(self, require_worker_task): + other = User.objects.create_user(username="quick-image-other", password="pass") + other_team = Team.objects.create(name="Image Other Team", owner=other) + foreign = Product.objects.create(team=other_team, created_by=other, title="别人的图") + response = self.client.post( + "/api/projects/quick-create/", + { + "name": "偷图", + "source_product_id": str(foreign.id), + }, + format="multipart", + ) + self.assertEqual(response.status_code, 400) + self.assertFalse(Product.objects.filter(title="偷图").exists()) + + def test_status_returns_saved_product_images(self): + asset = Asset.objects.create( + team=self.team, + created_by=self.user, + name="主图.png", + asset_type=Asset.Type.IMAGE, + source=Asset.Source.UPLOAD, + category=Asset.Category.PRODUCT_IMAGE, + ) + AssetFile.objects.create( + asset=asset, + object_key="quick/product.png", + bucket="test", + content_type="image/png", + preview_url="https://cdn.example/product.png", + is_primary=True, + ) + product = Product.objects.create(team=self.team, created_by=self.user, title="带图商品", cover_asset=asset) + ProductImage.objects.create(product=product, asset=asset, sort_order=0, is_primary=True) + project = Project.objects.create(team=self.team, created_by=self.user, product=product, name="带图项目") + job = QuickCreateJob.objects.create(team=self.team, created_by=self.user, project=project) + + response = self.client.get(f"/api/projects/quick-create-status/{job.id}/") + self.assertEqual(response.status_code, 200) + self.assertEqual(response.data["product_images"], [{"asset_id": str(asset.id), "url": "https://cdn.example/product.png"}]) + + @patch("apps.projects.views.require_worker_task") + def test_submit_rejects_non_image(self, require_worker_task): + response = self.client.post( + "/api/projects/quick-create/", + { + "name": "错误文件", + "images": [SimpleUploadedFile("note.txt", b"text", content_type="text/plain")], + }, + format="multipart", + ) + self.assertEqual(response.status_code, 400) + self.assertFalse(Product.objects.filter(title="错误文件").exists()) + + @patch("apps.projects.views.get_default_model", return_value=object()) + @patch("apps.projects.views.require_worker_task") + def test_submit_rejects_resolution_not_supported_by_selected_model(self, require_worker_task, get_model): + provider = ModelProvider.objects.create(name="quick-video-provider", display_name="Quick", status="active") + model = ModelConfig.objects.create( + provider=provider, + name="quick-fast", + display_name="Quick Fast", + capability=ModelConfig.Capability.VIDEO, + status=ModelConfig.Status.ACTIVE, + metadata={"capabilities": {"resolutions": ["480p", "720p"], "durations": [15]}}, + ) + response = self.client.post( + "/api/projects/quick-create/", + { + "name": "不兼容清晰度", + "images": [SimpleUploadedFile("front.png", b"png", content_type="image/png")], + "video_model_config_id": str(model.id), + "resolution": "1080p", + }, + format="multipart", + ) + self.assertEqual(response.status_code, 400) + self.assertIn("不支持 1080P", response.data["detail"]) + self.assertFalse(Product.objects.filter(title="不兼容清晰度").exists()) + + def test_status_is_team_scoped(self): + product = Product.objects.create(team=self.team, created_by=self.user, title="本团队商品") + project = Project.objects.create(team=self.team, created_by=self.user, product=product, name="本团队项目") + job = QuickCreateJob.objects.create(team=self.team, created_by=self.user, project=project) + + other = User.objects.create_user(username="quick-other", password="pass") + other_team = Team.objects.create(name="Other Team", owner=other) + TeamMember.objects.create(team=other_team, user=other, role=TeamMember.Role.OWNER) + other_client = APIClient() + other_client.force_authenticate(other) + + self.assertEqual(other_client.get(f"/api/projects/quick-create-status/{job.id}/").status_code, 404) + + def test_cancel_stops_running_job(self): + product = Product.objects.create(team=self.team, created_by=self.user, title="待取消商品") + project = Project.objects.create(team=self.team, created_by=self.user, product=product, name="待取消项目") + job = QuickCreateJob.objects.create( + team=self.team, + created_by=self.user, + project=project, + status=QuickCreateJob.Status.RUNNING, + phase=QuickCreateJob.Phase.SCRIPT, + message="正在根据商品名称与图片生成带货脚本", + ) + + response = self.client.post(f"/api/projects/quick-create-cancel/{job.id}/") + + self.assertEqual(response.status_code, 200) + job.refresh_from_db() + self.assertEqual(job.status, QuickCreateJob.Status.CANCELLED) + self.assertEqual(response.data["status"], "cancelled") + self.assertIn("取消", response.data["error_message"]) + + def test_cancel_is_team_scoped(self): + product = Product.objects.create(team=self.team, created_by=self.user, title="本团队商品") + project = Project.objects.create(team=self.team, created_by=self.user, product=product, name="本团队项目") + job = QuickCreateJob.objects.create(team=self.team, created_by=self.user, project=project) + + other = User.objects.create_user(username="quick-cancel-other", password="pass") + other_team = Team.objects.create(name="Cancel Other Team", owner=other) + TeamMember.objects.create(team=other_team, user=other, role=TeamMember.Role.OWNER) + other_client = APIClient() + other_client.force_authenticate(other) + + self.assertEqual(other_client.post(f"/api/projects/quick-create-cancel/{job.id}/").status_code, 404) + + def test_history_lists_succeeded_jobs_for_team(self): + product = Product.objects.create(team=self.team, created_by=self.user, title="历史商品") + project = Project.objects.create(team=self.team, created_by=self.user, product=product, name="历史商品 · 极速成片") + QuickCreateJob.objects.create( + team=self.team, + created_by=self.user, + project=project, + status=QuickCreateJob.Status.SUCCEEDED, + phase=QuickCreateJob.Phase.COMPLETE, + ) + other = User.objects.create_user(username="quick-history-other", password="pass") + other_team = Team.objects.create(name="History Other Team", owner=other) + TeamMember.objects.create(team=other_team, user=other, role=TeamMember.Role.OWNER) + other_product = Product.objects.create(team=other_team, created_by=other, title="别人的商品") + other_project = Project.objects.create(team=other_team, created_by=other, product=other_product, name="别人的项目") + QuickCreateJob.objects.create( + team=other_team, + created_by=other, + project=other_project, + status=QuickCreateJob.Status.SUCCEEDED, + phase=QuickCreateJob.Phase.COMPLETE, + ) + + response = self.client.get("/api/projects/quick-create-history/") + self.assertEqual(response.status_code, 200) + self.assertEqual(response.data["count"], 1) + self.assertEqual(response.data["results"][0]["product_name"], "历史商品") + self.assertEqual(response.data["results"][0]["title"], "历史商品 · 极速成片") + + def test_list_serializer_flags_quick_create_projects(self): + product = Product.objects.create(team=self.team, created_by=self.user, title="列表商品") + quick = Project.objects.create( + team=self.team, + created_by=self.user, + product=product, + name="列表商品 · 极速成片", + metadata={"quick_create": True}, + ) + normal = Project.objects.create( + team=self.team, + created_by=self.user, + product=product, + name="列表商品 · 专业创作", + ) + self.assertTrue(ProjectListSerializer(quick).data["quick_create"]) + self.assertFalse(ProjectListSerializer(normal).data["quick_create"]) + + +class QuickCreateCoordinatorTests(TestCase): + def setUp(self): + self.user = User.objects.create_user(username="quick-worker", password="pass") + self.team = Team.objects.create(name="Worker Team", owner=self.user) + TeamMember.objects.create(team=self.team, user=self.user, role=TeamMember.Role.OWNER) + self.product = Product.objects.create(team=self.team, created_by=self.user, title="测试精华") + self.project = Project.objects.create( + team=self.team, + created_by=self.user, + product=self.product, + name="测试精华 · 极速成片", + status=Project.Status.SCRIPTING, + current_stage=ProjectStage.Stage.SCRIPT, + ) + initialize_project_pipeline(self.project) + self.job = QuickCreateJob.objects.create(team=self.team, created_by=self.user, project=self.project) + + @patch("apps.projects.tasks.run_quick_script_task.apply_async") + def test_first_advance_recognizes_product_then_starts_script(self, start_script): + delay = advance_quick_create(str(self.job.id)) + self.job.refresh_from_db() + self.assertEqual(delay, 1) + self.assertEqual(self.job.status, QuickCreateJob.Status.RUNNING) + self.assertEqual(self.job.phase, QuickCreateJob.Phase.SCRIPT) + start_script.assert_not_called() + + delay = advance_quick_create(str(self.job.id)) + self.job.refresh_from_db() + self.assertEqual(delay, 5) + self.assertTrue(self.job.metadata.get("script_started")) + start_script.assert_called_once() + self.assertEqual(start_script.call_args.kwargs["args"], [str(self.job.id)]) + self.assertEqual(start_script.call_args.kwargs["queue"], "airshelf.quick") + + def test_adopted_script_moves_to_assets_without_rerunning(self): + script = ScriptVersion.objects.create(project=self.project, title="脚本", content="口播", is_adopted=True) + ScriptSegment.objects.create(script_version=script, sort_order=0, narration="开场") + self.job.status = QuickCreateJob.Status.RUNNING + self.job.phase = QuickCreateJob.Phase.SCRIPT + self.job.save(update_fields=["status", "phase", "updated_at"]) + + delay = advance_quick_create(str(self.job.id)) + self.job.refresh_from_db() + self.assertEqual(delay, 1) + self.assertEqual(self.job.phase, QuickCreateJob.Phase.ASSETS) + + def test_cancelled_job_does_not_keep_advancing(self): + cancel_quick_create(self.job) + delay = advance_quick_create(str(self.job.id)) + self.job.refresh_from_db() + self.assertIsNone(delay) + self.assertEqual(self.job.status, QuickCreateJob.Status.CANCELLED) + + @patch("apps.projects.services.quick_create._run_quick_script_in_thread") + def test_recover_reruns_script_locally_when_queue_drops_it(self, run_local): + self.job.status = QuickCreateJob.Status.RUNNING + self.job.phase = QuickCreateJob.Phase.SCRIPT + self.job.metadata = { + "script_started": True, + "script_started_at": (timezone.now() - timedelta(minutes=5)).isoformat(), + } + self.job.save(update_fields=["status", "phase", "metadata", "updated_at"]) + + recover_quick_create(self.job) + self.job.refresh_from_db() + self.assertTrue(self.job.metadata.get("script_local")) + self.assertEqual(self.job.status, QuickCreateJob.Status.RUNNING) + run_local.assert_called_once_with(str(self.job.id)) + + def test_recover_marks_success_when_video_already_finished(self): + self.project.video_segments.exclude(sort_order=0).delete() + segment = self.project.video_segments.get(sort_order=0) + asset = Asset.objects.create( + team=self.team, + created_by=self.user, + name="已完成视频", + asset_type=Asset.Type.VIDEO, + source=Asset.Source.AI_GENERATED, + category=Asset.Category.VIDEO_CLIP, + ) + version = VideoSegmentVersion.objects.create(video_segment=segment, asset=asset, is_adopted=True) + segment.adopted_version = version + segment.status = VideoSegment.Status.SUCCEEDED + segment.save(update_fields=["adopted_version", "status", "updated_at"]) + self.job.status = QuickCreateJob.Status.FAILED + self.job.phase = QuickCreateJob.Phase.PRODUCTION + self.job.error_message = "极速成片暂未完成,请稍后重试或进入专业模式查看" + self.job.save(update_fields=["status", "phase", "error_message", "updated_at"]) + + recover_quick_create(self.job) + self.job.refresh_from_db() + self.assertEqual(self.job.status, QuickCreateJob.Status.SUCCEEDED) + self.assertEqual(self.job.phase, QuickCreateJob.Phase.COMPLETE) + + @patch("apps.projects.services.quick_create._reviews_ready", return_value=True) + def test_production_counts_ready_videos_without_adding_version_ids(self, _reviews): + self.project.video_segments.exclude(sort_order=0).delete() + segment = self.project.video_segments.get(sort_order=0) + asset = Asset.objects.create( + team=self.team, + created_by=self.user, + name="计数视频", + asset_type=Asset.Type.VIDEO, + source=Asset.Source.AI_GENERATED, + category=Asset.Category.VIDEO_CLIP, + ) + version = VideoSegmentVersion.objects.create(video_segment=segment, asset=asset, is_adopted=True) + segment.adopted_version = version + segment.status = VideoSegment.Status.SUCCEEDED + segment.save(update_fields=["adopted_version", "status", "updated_at"]) + self.job.status = QuickCreateJob.Status.RUNNING + self.job.phase = QuickCreateJob.Phase.PRODUCTION + self.job.metadata = {"storyboard_started": True, "video_started": True} + self.job.save(update_fields=["status", "phase", "metadata", "updated_at"]) + self.project.current_stage = ProjectStage.Stage.VIDEO + self.project.save(update_fields=["current_stage", "updated_at"]) + + delay = advance_quick_create(str(self.job.id)) + self.job.refresh_from_db() + self.assertIsNone(delay) + self.assertEqual(self.job.status, QuickCreateJob.Status.SUCCEEDED) + + def test_success_serializer_returns_direct_single_segment_video(self): + self.project.video_segments.exclude(sort_order=0).delete() + segment = self.project.video_segments.get(sort_order=0) + asset = Asset.objects.create( + team=self.team, + created_by=self.user, + name="极速成片", + asset_type=Asset.Type.VIDEO, + source=Asset.Source.AI_GENERATED, + category=Asset.Category.VIDEO_CLIP, + ) + AssetFile.objects.create( + asset=asset, + object_key="quick/final.mp4", + bucket="test", + content_type="video/mp4", + preview_url="https://cdn.example/quick.mp4", + is_primary=True, + ) + version = VideoSegmentVersion.objects.create(video_segment=segment, asset=asset, is_adopted=True) + segment.adopted_version = version + segment.status = VideoSegment.Status.SUCCEEDED + segment.save(update_fields=["adopted_version", "status", "updated_at"]) + self.job.status = QuickCreateJob.Status.SUCCEEDED + self.job.phase = QuickCreateJob.Phase.COMPLETE + self.job.progress = 100 + self.job.save(update_fields=["status", "phase", "progress", "updated_at"]) + + data = QuickCreateJobSerializer(self.job).data + self.assertEqual(data["phase_index"], 3) + self.assertEqual(data["result"]["video_url"], "https://cdn.example/quick.mp4") + self.assertEqual(data["result"]["duration_seconds"], 15) + + @patch("apps.projects.tasks.poll_video_segment_task.apply_async") + @patch("apps.projects.services.quick_create.submit_video_segment") + def test_video_start_uses_quick_create_core_parameters(self, submit_video, schedule_poll): + self.project.metadata = { + "wizard": { + "aspect_ratio": "16:9", + "resolution": "1080p", + "total_duration": 30, + "video_model_config_id": "11111111-1111-4111-8111-111111111111", + } + } + self.project.save(update_fields=["metadata", "updated_at"]) + self.project.video_segments.exclude(sort_order__lt=2).delete() + + _start_videos(self.job) + + self.assertEqual(submit_video.call_count, 2) + for call in submit_video.call_args_list: + self.assertEqual(call.kwargs["aspect_ratio"], "16:9") + self.assertEqual(call.kwargs["resolution"], "1080p") + self.assertEqual(call.kwargs["model_config_id"], "11111111-1111-4111-8111-111111111111") + self.job.refresh_from_db() + self.assertIn("30秒 16:9", self.job.message) diff --git a/core/backend/apps/projects/tests.py b/core/backend/apps/projects/tests.py index f1ee445..f91b1d5 100644 --- a/core/backend/apps/projects/tests.py +++ b/core/backend/apps/projects/tests.py @@ -834,6 +834,26 @@ class ProjectApiTests(TestCase): self.assertIn("[avoice][vo0][vo1]amix=inputs=3", graph) self.assertIn("[anarr][abgm]amix=inputs=2", graph) + def test_export_command_uses_selected_output_size(self): + """极速成片选横屏/清晰度后,多段合成必须沿用该尺寸,不能回退成固定竖屏。""" + from apps.projects.services.export import _build_export_command + + cmd = _build_export_command( + n=1, + specs=[{"ts": 0.0, "te": 15.0, "dur": 15.0}], + starts=[0.0], + total=15.0, + transition="none", + sub_overlays=[], + bgm_name=None, + bgm_volume=1.0, + output_width=1920, + output_height=1080, + ) + graph = cmd[cmd.index("-filter_complex") + 1] + self.assertIn("scale=1920:1080", graph) + self.assertIn("pad=1920:1080", graph) + def test_save_timeline_updates_voiceover_offsets(self): """拖动字幕块后保存:按 asset 回写句内起点 offset_ms,未拖动的句不受影响。""" project = Project.objects.create(team=self.team, created_by=self.user, product=self.product, name="VOFF") diff --git a/core/backend/apps/projects/views.py b/core/backend/apps/projects/views.py index 8b0bd57..93516b5 100644 --- a/core/backend/apps/projects/views.py +++ b/core/backend/apps/projects/views.py @@ -44,9 +44,10 @@ from apps.assets.models import Asset, AssetFile from apps.assets.serializers import AssetFileSerializer from apps.assets.storage import TosStorage from apps.common.api import TeamScopedViewSetMixin -from apps.common.celery_health import require_worker +from apps.common.celery_health import require_worker, require_worker_task from apps.ai.generation_errors import classify_generation_error, public_error_for_task from apps.ai.video_digest import VideoDigestError, digest_project_video +from apps.products.models import Product, ProductImage, ProductSellingPoint from .models import ( BaseAssetGroup, @@ -54,6 +55,7 @@ from .models import ( ExportJob, Project, ProjectStage, + QuickCreateJob, ScriptSegment, ScriptTemplate, ScriptVersion, @@ -72,6 +74,7 @@ from .serializers import ( ExportJobSerializer, ProjectListSerializer, ProjectSerializer, + QuickCreateJobSerializer, ScriptTemplateSerializer, ScriptVersionSerializer, StoryboardVersionSerializer, @@ -79,10 +82,15 @@ from .serializers import ( is_playable_video, ) from .services.export import run_export_job_in_thread -from .services.pipeline import STAGE_ORDER +from .services.pipeline import ( + adopt_script_version, + finish_storyboard_stage, + initialize_project_pipeline, + sync_video_segments_to_script, +) from .services.script_import import ScriptFileError, extract_script_text from .services.templates import build_template_fields, coerce_persona, coerce_template_combo, render_outline_text -from .tasks import poll_video_segment_task +from .tasks import advance_quick_create_task, poll_video_segment_task logger = logging.getLogger(__name__) @@ -101,7 +109,12 @@ class ServerSentEventRenderer(BaseRenderer): def _store_uploaded_asset(*, team, user, upload, asset_type: str, category: str, name: str) -> Asset: """把上传的文件落到 TOS,建 Asset+AssetFile(主文件)。供上传视频段 / 上传 BGM 复用。""" - suffix = Path(upload.name).suffix.lower() or (".mp4" if asset_type == Asset.Type.VIDEO else ".mp3") + fallback_suffix = { + Asset.Type.IMAGE: ".png", + Asset.Type.VIDEO: ".mp4", + Asset.Type.AUDIO: ".mp3", + }.get(asset_type, ".bin") + suffix = Path(upload.name).suffix.lower() or fallback_suffix asset_id = uuid.uuid4() object_key = f"teams/{team.id}/uploads/{asset_id}{suffix}" stored = TosStorage().upload_fileobj( @@ -435,17 +448,271 @@ class ProjectViewSet(TeamScopedViewSetMixin, ModelViewSet): by_status = {row["status"]: row["n"] for row in base.values("status").annotate(n=Count("id"))} return Response({"total": base.count(), "by_status": by_status}) + @action( + detail=False, + methods=["post"], + url_path="quick-create", + parser_classes=[MultiPartParser, FormParser], + ) + def quick_create(self, request): + """商品名称 + 1–9 张图 → 新建商品与项目,并启动完整自动生产流水线。""" + require_worker_task("apps.projects.tasks.advance_quick_create_task") + name = str(request.data.get("name") or "").strip() + uploads = request.FILES.getlist("images") or request.FILES.getlist("images[]") + source_product_id = str(request.data.get("source_product_id") or "").strip() + requested_asset_ids = [ + str(value).strip() + for value in (request.data.getlist("image_asset_ids") or request.data.getlist("image_asset_ids[]") or []) + if str(value).strip() + ] + if not name: + return Response({"detail": "请填写商品名称"}, status=status.HTTP_400_BAD_REQUEST) + if len(name) > 255: + return Response({"detail": "商品名称不能超过255个字符"}, status=status.HTTP_400_BAD_REQUEST) + if not uploads and not source_product_id: + return Response({"detail": "请至少上传一张商品图片"}, status=status.HTTP_400_BAD_REQUEST) + if len(uploads) > 9: + return Response({"detail": "商品图片最多上传9张"}, status=status.HTTP_400_BAD_REQUEST) + allowed_types = {"image/jpeg", "image/png", "image/webp"} + for upload in uploads: + if (upload.content_type or "").lower() not in allowed_types: + return Response({"detail": "仅支持 JPG、PNG 或 WebP 商品图片"}, status=status.HTTP_400_BAD_REQUEST) + if upload.size > 20 * 1024 * 1024: + return Response({"detail": "单张商品图片不能超过20MB"}, status=status.HTTP_400_BAD_REQUEST) + + aspect_ratio = str(request.data.get("aspect_ratio") or "9:16") + resolution = str(request.data.get("resolution") or "720p").lower() + try: + total_duration = int(request.data.get("total_duration") or 15) + except (TypeError, ValueError): + total_duration = 0 + if aspect_ratio not in {"21:9", "16:9", "4:3", "1:1", "3:4", "9:16"}: + return Response({"detail": "请选择有效的视频比例"}, status=status.HTTP_400_BAD_REQUEST) + if resolution not in {"480p", "720p", "1080p", "4k"}: + return Response({"detail": "请选择有效的视频分辨率"}, status=status.HTTP_400_BAD_REQUEST) + if total_duration not in {15, 30, 45, 60}: + return Response({"detail": "视频时长仅支持15、30、45或60秒"}, status=status.HTTP_400_BAD_REQUEST) + + text_model = get_default_model(ModelConfig.Capability.TEXT) + image_model = get_default_model(ModelConfig.Capability.IMAGE) + requested_video_model_id = str(request.data.get("video_model_config_id") or "").strip() + video_model = None + if requested_video_model_id: + try: + video_model_uuid = uuid.UUID(requested_video_model_id) + except (TypeError, ValueError): + return Response({"detail": "请选择有效的视频模型"}, status=status.HTTP_400_BAD_REQUEST) + video_model = ( + ModelConfig.objects.select_related("provider") + .filter( + id=video_model_uuid, + capability=ModelConfig.Capability.VIDEO, + status=ModelConfig.Status.ACTIVE, + provider__status="active", + ) + .first() + ) + else: + video_model = get_default_model(ModelConfig.Capability.VIDEO) + + missing = [ + label + for model, label in ((text_model, "文本"), (image_model, "图像"), (video_model, "视频")) + if model is None + ] + if missing: + return Response( + {"detail": f"当前缺少可用的{'、'.join(missing)}模型,请联系管理员配置"}, + status=status.HTTP_503_SERVICE_UNAVAILABLE, + ) + capabilities = dict((video_model.metadata or {}).get("capabilities") or {}) + supported_resolutions = set(capabilities.get("resolutions") or (video_model.metadata or {}).get("resolutions") or []) + supported_ratios = set(capabilities.get("aspect_ratios") or []) + supported_durations = set(capabilities.get("durations") or (video_model.metadata or {}).get("durations") or []) + if supported_resolutions and resolution not in supported_resolutions: + return Response( + {"detail": f"{video_model.display_name} 不支持 {resolution.upper()},请更换清晰度或模型"}, + status=status.HTTP_400_BAD_REQUEST, + ) + if supported_ratios and aspect_ratio not in supported_ratios: + return Response( + {"detail": f"{video_model.display_name} 不支持 {aspect_ratio} 比例"}, + status=status.HTTP_400_BAD_REQUEST, + ) + # 极速成片把总时长拆成多个15秒镜头,视频模型只需支持单镜15秒。 + if supported_durations and 15 not in supported_durations: + return Response( + {"detail": f"{video_model.display_name} 不支持15秒单镜生成"}, + status=status.HTTP_400_BAD_REQUEST, + ) + + team = self.get_team() + reused_assets = [] + if source_product_id: + try: + source_uuid = uuid.UUID(source_product_id) + except (TypeError, ValueError): + return Response({"detail": "找不到可复用的商品图片"}, status=status.HTTP_400_BAD_REQUEST) + source = ( + Product.objects.filter(id=source_uuid, team=team) + .prefetch_related("images__asset") + .first() + ) + if source is None: + return Response({"detail": "找不到可复用的商品图片"}, status=status.HTTP_400_BAD_REQUEST) + source_images = list(source.images.all()) + by_asset_id = {str(image.asset_id): image.asset for image in source_images} + if requested_asset_ids: + if any(asset_id not in by_asset_id for asset_id in requested_asset_ids): + return Response({"detail": "找不到可复用的商品图片"}, status=status.HTTP_400_BAD_REQUEST) + reused_assets = [by_asset_id[asset_id] for asset_id in requested_asset_ids] + else: + reused_assets = [image.asset for image in source_images] + if not reused_assets and not uploads: + return Response({"detail": "请至少上传一张商品图片"}, status=status.HTTP_400_BAD_REQUEST) + if len(reused_assets) + len(uploads) > 9: + return Response({"detail": "商品图片最多上传9张"}, status=status.HTTP_400_BAD_REQUEST) + + with transaction.atomic(): + assets = list(reused_assets) + assets.extend( + _store_uploaded_asset( + team=team, + user=request.user, + upload=upload, + asset_type=Asset.Type.IMAGE, + category=Asset.Category.PRODUCT_IMAGE, + name=upload.name or f"{name}-商品图{index + 1}", + ) + for index, upload in enumerate(uploads) + ) + if not assets: + return Response({"detail": "请至少上传一张商品图片"}, status=status.HTTP_400_BAD_REQUEST) + product = Product.objects.create( + team=team, + created_by=request.user, + title=name, + business_type=Product.BusinessType.ECOMMERCE, + cover_asset=assets[0], + ) + ProductImage.objects.bulk_create( + [ + ProductImage( + product=product, + asset=asset, + sort_order=index, + is_primary=index == 0, + ) + for index, asset in enumerate(assets) + ] + ) + project = Project.objects.create( + team=team, + created_by=request.user, + product=product, + name=f"{name} · 极速成片", + status=Project.Status.SCRIPTING, + current_stage=ProjectStage.Stage.SCRIPT, + metadata={ + "quick_create": True, + "wizard": { + "aspect_ratio": aspect_ratio, + "resolution": resolution, + "total_duration": total_duration, + "video_model_config_id": str(video_model.id), + "video_model_name": video_model.name, + "video_model_label": video_model.display_name, + "presentation_format": "oral", + "video_structure": "pain", + "persona": "reviewer", + }, + }, + ) + initialize_project_pipeline(project, placeholder_segments=max(1, total_duration // 15)) + if not product.selling_points.exists(): + ProductSellingPoint.objects.create( + product=product, + title=name, + detail="由极速成片根据商品名称自动填写", + sort_order=0, + ) + job = QuickCreateJob.objects.create( + team=team, + created_by=request.user, + project=project, + status=QuickCreateJob.Status.QUEUED, + phase=QuickCreateJob.Phase.PRODUCT, + progress=0, + message="等待开始极速成片", + ) + + try: + advance_quick_create_task.apply_async(args=[str(job.id)], queue="airshelf.quick") + except Exception as exc: # noqa: BLE001 — broker 极小窗口失败也必须给任务落终态 + from .services.quick_create import fail_quick_create + + logger.exception("quick create enqueue failed for job %s", job.id) + fail_quick_create(job, "生成队列暂时不可用,请稍后重试", internal_error=str(exc)) + return Response(QuickCreateJobSerializer(job).data, status=status.HTTP_503_SERVICE_UNAVAILABLE) + + job = self._quick_job_queryset().get(id=job.id) + return Response(QuickCreateJobSerializer(job).data, status=status.HTTP_202_ACCEPTED) + + def _quick_job_queryset(self): + return ( + QuickCreateJob.objects.select_related("project__product", "project__timeline") + .prefetch_related( + "project__product__images__asset__files", + "project__script_versions", + "project__base_asset_groups", + "project__storyboard_shots__adopted_version__asset__files", + "project__video_segments__adopted_version__asset__files", + "project__timeline__export_jobs__output_asset__files", + ) + .filter(team=self.get_team()) + ) + + @action(detail=False, methods=["get"], url_path=r"quick-create-status/(?P[^/.]+)") + def quick_create_status(self, request, job_id=None): + job = self._quick_job_queryset().filter(id=job_id).first() + if job is None: + return Response({"detail": "极速成片任务不存在"}, status=status.HTTP_404_NOT_FOUND) + from .services.quick_create import recover_quick_create + + try: + recover_quick_create(job) + except Exception: # noqa: BLE001 — 恢复失败不能把进度接口打成 500 + logger.exception("quick create recover failed for job %s", job.id) + job = self._quick_job_queryset().get(id=job.id) + return Response(QuickCreateJobSerializer(job).data) + + @action(detail=False, methods=["post"], url_path=r"quick-create-cancel/(?P[^/.]+)") + def quick_create_cancel(self, request, job_id=None): + job = self._quick_job_queryset().filter(id=job_id).first() + if job is None: + return Response({"detail": "极速成片任务不存在"}, status=status.HTTP_404_NOT_FOUND) + if job.status == QuickCreateJob.Status.SUCCEEDED: + return Response({"detail": "已完成的任务不能取消"}, status=status.HTTP_400_BAD_REQUEST) + from .services.quick_create import cancel_quick_create + + cancel_quick_create(job) + job = self._quick_job_queryset().get(id=job.id) + return Response(QuickCreateJobSerializer(job).data) + + @action(detail=False, methods=["get"], url_path="quick-create-history") + def quick_create_history(self, request): + jobs = self._quick_job_queryset().filter(status=QuickCreateJob.Status.SUCCEEDED).order_by("-created_at") + return Response({ + "count": jobs.count(), + "results": QuickCreateJobSerializer(jobs[:30], many=True).data, + }) + @transaction.atomic def perform_create(self, serializer): project = serializer.save(team=self.get_team(), created_by=self.request.user) self._apply_wizard_template(project) - for stage in STAGE_ORDER: - ProjectStage.objects.create(project=project, stage=stage) - # 先铺 4 段占位;真实段数与每段时长在采用脚本时由 _sync_video_segments_to_script 收口 - for index in range(4): - VideoSegment.objects.create( - project=project, sort_order=index, target_duration_seconds=SEGMENT_DURATION_MAX - ) + # 先铺 4 段占位;真实段数与每段时长在采用脚本时收口。 + initialize_project_pipeline(project, placeholder_segments=4) def _apply_wizard_template(self, project) -> None: """新建向导选了套路模板 → 后端按模板真值回填 metadata.wizard,并计一次使用。 @@ -583,19 +850,7 @@ class ProjectViewSet(TeamScopedViewSetMixin, ModelViewSet): project = self.get_object() script_id = request.data.get("script_version_id") script = ScriptVersion.objects.select_for_update().get(project=project, id=script_id) - ScriptVersion.objects.filter(project=project).update(is_adopted=False) - script.is_adopted = True - script.save(update_fields=["is_adopted", "updated_at"]) - # 采用脚本时把视频片段数对齐到这版分镜数:用户常在「采用前」就增删分镜, - # 那些编辑因 _sync 的 is_adopted 闸而未同步到 VideoSegment(项目创建时固定铺了 4 段), - # 不在此收口的话视频步骤会一直停在 4 段,与故事板/分镜数对不上。已生成的段绝不动。 - self._sync_video_segments_to_script(project, script) - stage, _ = ProjectStage.objects.get_or_create(project=project, stage=ProjectStage.Stage.SCRIPT) - stage.status = ProjectStage.Status.SUCCEEDED - stage.save(update_fields=["status", "updated_at"]) - project.current_stage = ProjectStage.Stage.BASE_ASSETS - project.status = Project.Status.ASSETING - project.save(update_fields=["current_stage", "status", "updated_at"]) + adopt_script_version(project, script) return Response(ScriptVersionSerializer(script).data) @action(detail=True, methods=["post"], url_path="extract-entities") @@ -900,47 +1155,7 @@ class ProjectViewSet(TeamScopedViewSetMixin, ModelViewSet): 时长同步:脚本镜主流程固定 15 秒,而出片、计价、时间线读的都是 VideoSegment.target_duration_seconds。不同步的话脚本写了 15 秒、出片仍按旧默认跑。 已出片的段不改时长——改了会跟已渲染的成片对不上。""" - if not script.is_adopted: - return - script_segments = list(script.segments.order_by("sort_order")) - target = len(script_segments) - segments = list(project.video_segments.order_by("sort_order")) - while len(segments) > target: - tail = segments[-1] - if tail.status == VideoSegment.Status.NOT_STARTED and not tail.versions.exists(): - tail.delete() - segments.pop() - else: - break - next_order = (segments[-1].sort_order + 1) if segments else 0 - for _ in range(target - len(segments)): - index = len(segments) - seconds = ( - script_segments[index].duration_seconds - if index < target - else SEGMENT_DURATION_MAX - ) - segments.append( - VideoSegment.objects.create( - project=project, sort_order=next_order, target_duration_seconds=seconds - ) - ) - next_order += 1 - - # 已存在的段:只对「还没出过片」的回填脚本时长,已渲染的保持原样 - stale: list[VideoSegment] = [] - for index, video_segment in enumerate(segments): - if index >= target: - break - seconds = script_segments[index].duration_seconds - if not seconds or video_segment.target_duration_seconds == seconds: - continue - if video_segment.status == VideoSegment.Status.SUCCEEDED or video_segment.versions.exists(): - continue - video_segment.target_duration_seconds = seconds - stale.append(video_segment) - if stale: - VideoSegment.objects.bulk_update(stale, ["target_duration_seconds"]) + sync_video_segments_to_script(project, script) def _sync_storyboard_shots_to_script(self, project: Project, script: ScriptVersion) -> None: """采用版分镜数变化时,同步 StoryboardShot 数量(与视频段同策略,按位置对齐): @@ -1133,16 +1348,7 @@ class ProjectViewSet(TeamScopedViewSetMixin, ModelViewSet): project = self.get_object() result = poll_storyboard(project=project, user=request.user) if result.get("status") == "succeeded": - stage, _ = ProjectStage.objects.get_or_create(project=project, stage=ProjectStage.Stage.STORYBOARD) - stage.status = ProjectStage.Status.SUCCEEDED - stage.save(update_fields=["status", "updated_at"]) - # 进入视频阶段前再收口一次视频片段数,确保与采用版分镜数一致(已生成的段不动) - adopted_script = project.script_versions.filter(is_adopted=True).order_by("-created_at").first() - if adopted_script is not None: - self._sync_video_segments_to_script(project, adopted_script) - project.current_stage = ProjectStage.Stage.VIDEO - project.status = Project.Status.VIDEOING - project.save(update_fields=["current_stage", "status", "updated_at"]) + finish_storyboard_stage(project) http_status = status.HTTP_200_OK if result.get("status") == "succeeded" else status.HTTP_202_ACCEPTED return Response(result, status=http_status) diff --git a/core/backend/skills/ecommerce-video-script/SKILL.md b/core/backend/skills/ecommerce-video-script/SKILL.md index fa97458..8f0c628 100644 --- a/core/backend/skills/ecommerce-video-script/SKILL.md +++ b/core/backend/skills/ecommerce-video-script/SKILL.md @@ -32,7 +32,7 @@ description: > "hook": "前3秒主打钩子(一句话)", "tone": "种草|测评|剧情|痛点", "presentation_format": "口播|短剧|Vlog", - "video_structure": "痛点解决|前后对比|测评验证|场景种草", + "video_structure": "痛点解决|前后对比|测评验证|场景种草|促销抢购|知识分享", "aspect_ratio": "9:16", "total_duration": 30, "segment_count": 3, @@ -59,7 +59,7 @@ description: > "index": 0, "duration": 15, "role": "钩子|痛点|卖点|CTA", - "narration": "这一镜被说出来的台词/旁白,15秒口播目标55-63字(下限54,上限67)", + "narration": "这一镜被说出来的台词/旁白,15秒口播目标66-74字(下限64,上限78)", "speaker": "可选,指向某 entity 的 id;画外旁白时为 null", "visual": "0-3s:近景,……\\n3-8s:手持跟拍,开始使用商品\\n8-12s:特写,商品用法过程\\n12-15s:拉回中近景,使用后的反应", "product_exposure": "商品露出方式(手持/特写/使用中)", @@ -73,7 +73,7 @@ description: > 字段纪律: - `tone` 必须是四选一枚举;`role` 必须是四选一枚举。 - **表现形式与视频结构由输入给定**:`presentation_format`(`口播|短剧|Vlog`)和 - `video_structure`(`痛点解决|前后对比|测评验证|场景种草`)**原样回填到输出里**,不要自己改。 + `video_structure`(`痛点解决|前后对比|测评验证|场景种草|促销抢购|知识分享`)**原样回填到输出里**,不要自己改。 输入没给时,按商品品类与人群自行选一组最合适的,并如实填进这两个字段。 - **画幅由输入给定**:`aspect_ratio` 默认 `"9:16"`(电商竖屏主场景),但**不写死**——输入指定了其他比例(如 `"16:9"`、`"1:1"`、`"4:5"`)就照用,原样透传给下游。画幅只影响 `visual` 的构图措辞,不改变结构与镜数。 - **总时长由输入给定**:`total_duration` 取 **15 / 30 / 45 / 60**(15 秒步进),输入未指定时默认 `30`。**不要写死成别的数。** @@ -96,14 +96,15 @@ description: > - **场景必抽,且每镜必绑一个场景**:每条脚本**至少声明 1 个 `type:"scene"` 实体**表示画面所在环境;**每个 segment 的 `entity_refs` 必须恰好引用一个 scene**。多镜在同一环境就**复用同一个 scene id**(绝不为同一环境写两份 visual_prompt,否则下游背景漂移);只有真正换了环境才新建另一个 scene。纯产品特写镜也要绑它所处环境的 scene(如「宿舍书桌」「厨房台面」),没有合适环境时复用主场景。 - `visual_prompt` 由你自动生成,小白无需打字。 - **每镜 `visual` 必须按秒拆分镜**:15 秒一场里至少 3 刀、目标 4 刀,写成多行: - `0-3s:景别;构图;运镜;谁在做什么;手和商品的空间关系;信息变化` - 第一条从 0 秒起,最后一条接到 15s。每条都要有景别/运镜,15 秒内至少切两次景别。 + `0-3s:景别;机位;运镜;谁在做什么;手和商品的空间关系;信息变化` + 第一条从 0 秒起,最后一条接到 15s。每条都要有景别/机位/运镜,15 秒内至少切两次景别。 **禁止**用一句静态动作撑满 15 秒(如「女主举起商品展示」)。这是给故事板和 Seedance 的导演说明书,**不占 narration 字数**。 + 输入是 `【镜头 01】` 分镜稿时,把原稿的景别、机位、运镜、动作、表情、音效、背景音乐、字幕、备注折进导演说明书,不要压成一句画面摘要。 - **`visual` 固定使用导演层级**:先写 `【本镜任务】`(这一段要完成的情绪/信息变化),再写 - `【声音】`(可发生的同期声、人声状态或「无配乐,仅同期声」),接着写 `【画面内容】`, + `【声音】`(台词/旁白状态、音效、背景音乐、字幕;没有写无),接着写 `【画面内容】`, 最后在该标题下列出 3–5 条秒级分镜。不要把基础设定、画面风格、镜头动作、声音、旁白混成一段散文。 -- **每条秒级分镜都写执行细节**:景别、构图、运镜、动作、信息变化五项至少有四项;例如 - `3-8s:中近景;人物位于画面左三分之一,商品从右下入画;手持跟拍;拧开瓶盖并挤出质地;观众第一次看清产品状态。` +- **每条秒级分镜都写执行细节**:景别、机位、运镜、动作、信息变化五项至少有四项;例如 + `3-8s:中近景;人物侧后方;手持跟拍;拧开瓶盖并挤出质地;观众第一次看清产品状态。` 这比「展示商品」更容易被分镜图和视频模型准确执行。 - **画面物理常识**:商品用法必须是真人会做的。茶/咖啡 = 热水、蒸汽、茶汤渐染,禁止茶包丢进看起来像冷白开的杯子;护肤品 = 挤出/涂抹;食品 = 打开/入口。手从真实方向入画,禁止悬浮肢体、反关节、商品凭空出现。每条分镜写清容器/包装长什么样、液体或材质当前状态。 - 不要输出 schema 之外的字段,也不要省略必填字段。 @@ -117,8 +118,8 @@ description: > 详见 `references/methodology.md`「旁白红线」。最关键的几条: - **口语化**,像真人对着镜头说话,不准书面腔 / AI 腔。 -- **口播 15 秒镜要说满**:`narration` **54–67 字**(写作目标 **55–63 字,3–5 句短句**)。 - 上限为 `duration × 4.5` 且不超过 68 字。禁止一句 20 字收工。 +- **口播 15 秒镜要说满**:`narration` **64–78 字**(写作目标 **66–74 字,4–5 句短句**)。 + 上限为 `duration × 5.2` 且不超过 78 字。禁止一句 20 字收工。 商品名全片点名 1–2 次即可,其余镜用卖点原词和可感知细节(口感/气味/动作/使用场景)。 Vlog 才允许个别镜纯画面;口播禁止整镜无声。 - **禁违规词**:医疗功效(治疗/根治/抗癌…)、绝对化用语(最/第一/100%/国家级…)一律不写。 @@ -139,7 +140,7 @@ description: > | 画幅 | **默认 9:16 竖屏**,由输入可覆盖(16:9 / 1:1 / 4:5 等照用) | | 总时长 | **15 / 30 / 45 / 60 秒**,由输入给定;未指定时默认 30 | | 表现形式 | **口播 / 短剧 / Vlog**,由输入给定;未指定时按品类人群自选 | -| 视频结构 | **痛点解决 / 前后对比 / 测评验证 / 场景种草**,由输入给定;未指定时按品类人群自选 | +| 视频结构 | **痛点解决 / 前后对比 / 测评验证 / 场景种草 / 促销抢购 / 知识分享**,由输入给定;未指定时按品类人群自选 | | 分镜 | **每镜固定 15 秒**;镜数 = 总时长 / 15 | | 镜头功能 | 钩子 → 痛点 → 卖点 → CTA(黄金结构;具体骨架以视频结构套路为准) | | 发声方式 | 出片模型直接生成画面+音效+人声(**不走 TTS**) | @@ -154,7 +155,7 @@ description: > | ---- | ---- | ---- | ---- | | **① 全自动** | 用户只给【商品信息 + 前置条件(表现形式/视频结构/时长/人群/卖点勾选)】,无主题无原稿 | 凭商品与前置条件,自动定镜数、选 tone、造 entity、按套路填镜 | `methodology.md` + `hook-library.md` + `category-playbook.md` + `platform-tone.md` | | **② 一句话** | 用户额外给了一句主题(如「主打熬夜党救星」) | 以该主题为脚本主轴扩写,其余同全自动 | 同上(主题优先于自动选题) | -| **③ 改稿 / 上传脚本** | 用户给了已有脚本/文案 | **保留用户原意**,先识别原稿结构与叙述顺序,再增强钩子/节奏/卖点证明/CTA,并归一化到 JSON 结构 | `methodology.md` + `hook-library.md` + `checklist.md` | +| **③ 改稿 / 上传脚本 / 上传视频提炼** | 用户给了已有脚本,或一份 `【镜头 01】` 导演分镜稿 | **脚本**:保留用户原意,增强钩子/节奏/卖点证明/CTA,并归一化到 JSON。**视频分镜稿**:照搬镜头顺序、每镜时长比例、景别、机位、运镜、人物动作和声音层次,把人物、商品、品牌、台词换成当前商品;`visual` 必须把景别/机位/运镜/动作/表情/音效/背景音乐/字幕/备注折进导演说明书,不要压成一句画面摘要 | `methodology.md` + `hook-library.md` + `checklist.md` | | 信息不足 | 连商品信息都缺,无法生成 | 唯一允许的回退:用一句话问清最少必要信息 | — | **进入任何模式前,必须先读取该行列出的参考资料。** @@ -165,7 +166,7 @@ description: > 并根据输入指定的表现形式与视频结构,**各读一份**: - `references/playbooks/format-{口播→oral | 短剧→drama | Vlog→vlog}.md` -- `references/playbooks/structure-{痛点解决→pain | 前后对比→contrast | 测评验证→review | 场景种草→scene}.md` +- `references/playbooks/structure-{痛点解决→pain | 前后对比→contrast | 测评验证→review | 场景种草→scene | 促销抢购→promo | 知识分享→knowledge}.md` > 运行时后端只会把被指定的那两份套路拼进上下文。**看到哪份就用哪份, > 不要凭记忆套用没加载进来的套路。** @@ -182,7 +183,7 @@ description: > 4. **抽取/创建 entities** — 识别脚本需要的角色 / 场景 / 商品;为每个 entity 写一份**全脚本共用**的 `visual_prompt`(保证多镜同一角色同一张脸);可选写 `voice_ref` 锁音色。 5. **按套路填结构** — 优先用视频结构套路里的骨架给每个 segment 分配 `role`; 套路没覆盖到的用「role 按镜数分配」表兜底;钩子镜套用该套路指定的钩子写法。 -6. **写 narration / visual / 商品露出** — 口播 15 秒镜旁白 55–63 字(下限 54)、口语化、过红线; +6. **写 narration / visual / 商品露出** — 口播 15 秒镜旁白 66–74 字(下限 64)、口语化、过红线; **visual 按秒拆成 3–5 条分镜**;必须把输入给出的卖点原词和商品描述细节写进旁白或对白,不要每句只重复商品名; 发声方式按表现形式套路来;每镜规划自然的 `product_exposure`。 7. **连引用** — 填 `entity_refs` 与 `speaker`,确认每个 entity 都被引用、id 都合法。 diff --git a/core/backend/skills/ecommerce-video-script/references/checklist.md b/core/backend/skills/ecommerce-video-script/references/checklist.md index b8f754b..8ea631c 100644 --- a/core/backend/skills/ecommerce-video-script/references/checklist.md +++ b/core/backend/skills/ecommerce-video-script/references/checklist.md @@ -11,7 +11,7 @@ - [ ] `tone` ∈ `{种草, 测评, 剧情, 痛点}`。 - [ ] `aspect_ratio` 存在;输入指定了就用输入值,未指定为 `"9:16"`。 - [ ] `presentation_format` ∈ `{口播, 短剧, Vlog}`,且与输入一致(输入没给则自选并如实填写)。 -- [ ] `video_structure` ∈ `{痛点解决, 前后对比, 测评验证, 场景种草}`,同上。 +- [ ] `video_structure` ∈ `{痛点解决, 前后对比, 测评验证, 场景种草, 促销抢购, 知识分享}`,同上。 - [ ] **组合合法**:不是「短剧 × 测评验证」(唯一禁用组合,见 `playbooks/combo-matrix.md`)。 - [ ] `total_duration` 是 **15 / 30 / 45 / 60**。 - [ ] **每个 `segment.duration` 必须是 15**。 @@ -35,6 +35,8 @@ - [ ] 顶层 `hook` 与钩子镜口径一致。 - [ ] 卖点镜的卖点**挂在前面铺的痛点上**,不是干罗列参数。 - [ ] CTA 给了明确动作(点小黄车/领券/主页链接)。**场景种草结构的 CTA 要软**,别硬喊。 +- [ ] **促销抢购**:所有价格、优惠、库存、赠品、开售时间和适用条件均来自商品资料;缺失就不写,绝不虚构限时限量。 +- [ ] **知识分享**:知识点可由商品资料支持,商品是在判断方法建立后自然出现;没有虚构专业身份、数据、标准或功效。 - [ ] **结构骨架走完了**:段的顺序与功能符合 `playbooks/structure-*.md` 里那份骨架表。 - [ ] **表现形式的镜头语言到位**:景别变化、单镜时长、人物数量符合 `playbooks/format-*.md`。 - [ ] role 序列符合结构套路;套路没覆盖的部分回落到 `methodology.md`「role 按镜数分配」表。 @@ -46,9 +48,9 @@ ## C. 旁白红线扫描(逐镜) -- [ ] **每镜 `narration` ≤ `duration × 4.5` 字,且绝不超过 68 字**(15 秒镜 ≤67 字)。 -- [ ] **口播 15 秒镜旁白 ≥ 54 字**(写作目标 55–63 字,3–5 句)。禁止一句 20 字收工;口播禁止整镜无声。 -- [ ] **秒级分镜**:每镜 `visual` 至少 3 条 `0-3s:景别,动作`,从 0 接到 15s;15 秒内至少切两次景别。 +- [ ] **每镜 `narration` ≤ `duration × 5.2` 字,且绝不超过 78 字**(15 秒镜 ≤78 字)。 +- [ ] **口播 15 秒镜旁白 ≥ 64 字**(写作目标 66–74 字,4–5 句)。禁止一句 20 字收工;口播禁止整镜无声。 +- [ ] **秒级分镜**:每镜 `visual` 至少 3 条 `0-3s:景别;机位;运镜;动作`,从 0 接到 15s;15 秒内至少切两次景别。上传视频提炼稿还要把音效/背景音乐/字幕折进 `【声音】`。 - [ ] **商品原词**:旁白/对白出现了输入给出的至少一个勾选卖点原词;商品名全片点名 1–2 次即可,其余镜写描述/卖点细节,没有用空话替换。 - [ ] 每镜 `visual` 能撑满 15 秒(至少 72 字),写清手/商品/容器的空间关系和真实用法,不是一句静态动作。 - [ ] 口语化,无书面腔/AI 腔("综上""不仅…而且""值得一提"等已清除)。 diff --git a/core/backend/skills/ecommerce-video-script/references/methodology.md b/core/backend/skills/ecommerce-video-script/references/methodology.md index 96eb11a..9f24f99 100644 --- a/core/backend/skills/ecommerce-video-script/references/methodology.md +++ b/core/backend/skills/ecommerce-video-script/references/methodology.md @@ -96,8 +96,8 @@ - **口语化**:像真人对着镜头唠嗑,不准书面腔 / 不准 AI 腔("综上所述""不仅…而且""值得一提的是"全禁)。 - **必须用商品原词**:输入给了商品名、品牌、卖点,就用这些词,不许改成「补水/好用/值得买」这种空卖点。品类示例句只是语气参考。 - **`narration` 字数按这一镜的秒数算**:出片模型在镜内直接发声。 - 可懂语速上限约 **4.5 字 / 秒**,口播下限约 **3.6 字 / 秒**(再短镜头空一半)。 - **每镜上限 `duration × 4.5` 字,且不超过 68 字。口播 15 秒镜还有下限 54 字。** + 可懂语速上限约 **5.2 字 / 秒**,口播下限约 **4.3 字 / 秒**(再短镜头空一半)。 + **每镜上限 `duration × 5.2` 字,且不超过 78 字。口播 15 秒镜还有下限 64 字。** | 单镜时长 | 下限 | 字数上限 | 写作目标 | | ---- | ---- | ---- | ---- | @@ -107,7 +107,7 @@ | 8 秒 | — | 28 字 | ≤ 25 字 | | 10 秒 | — | 35 字 | ≤ 31 字 | | 12 秒 | — | 42 字 | ≤ 38 字 | - | 15 秒 | **54 字** | 67 字 | **55–63 字,3–5 句** | + | 15 秒 | **64 字** | 78 字 | **66–74 字,4–5 句** | 主流程每镜都是 15 秒:**要说满,不要宁可短。** 一句 20 字撑不满镜头。 留白靠句间停顿和画面,不靠少写字。只有 Vlog 才允许个别镜纯画面无声。 @@ -132,13 +132,15 @@ ``` 【本镜任务】本段要让观众看懂的变化或悬念 -【声音】无配乐,仅同期声:包装摩擦声、倒水声;人物说话自然停顿 +【声音】台词/旁白状态;音效;背景音乐;字幕(没有写无) 【画面内容】 -0-3s:景别;构图;运镜;动作;信息变化 -3-8s:景别;构图;运镜;动作;信息变化 +0-3s:景别;机位;运镜;动作;表情;信息变化 +3-8s:景别;机位;运镜;动作;表情;信息变化 ... ``` +输入如果是 `【镜头 01】` 导演分镜稿(上传视频提炼),把原稿每镜的景别、机位、运镜、人物动作、表情、音效、背景音乐、字幕、备注折进对应栏,不要压成一句画面摘要。原稿写「无 / 不可见 / 听不清」的不要编造。 + 这样「人物/商品/场景设定」留在 entities 中保持全片一致,「本镜任务」管情绪推进, 「画面内容」只管可执行的镜头,声音也不会漏给视频模型。不得写虚构或无法发生的音效。 @@ -150,7 +152,7 @@ ``` - 第一条从 **0 秒**起,最后一条接到 **15s**。 -- 每一条写:**景别 + 构图 + 运镜 + 动作 + 信息变化**(至少四项),并写清谁、手从哪来、商品/容器什么样。 +- 每一条写:**景别 + 机位 + 运镜 + 动作 + 信息变化**(至少四项),并写清谁、手从哪来、商品/容器什么样。 - 15 秒内至少切 **两次景别**(近景 / 特写 / 手持跟拍…)。 - 商品用法必须真实:茶要热水和蒸汽,不要把茶包丢进冷白开;手不要悬浮。 - **一场一个动作链**:把 15 秒拆成同一件事的起因 → 操作 → 可见变化 → 反应,不能把无关的 diff --git a/core/backend/skills/ecommerce-video-script/references/playbooks/combo-matrix.md b/core/backend/skills/ecommerce-video-script/references/playbooks/combo-matrix.md index e76dcab..52d0653 100644 --- a/core/backend/skills/ecommerce-video-script/references/playbooks/combo-matrix.md +++ b/core/backend/skills/ecommerce-video-script/references/playbooks/combo-matrix.md @@ -2,7 +2,7 @@ > 表现形式决定**怎么拍**(谁在画面里、镜头怎么动、谁在说话)。 > 视频结构决定**怎么说服**(信息按什么顺序给、靠什么建立可信)。 -> 两者正交:同一个结构用三种形式拍出来完全不同,同一种形式套四个结构说服力也不同。 +> 两者正交:同一个结构用三种形式拍出来完全不同,同一种形式套六个结构说服力也不同。 **加载规则:** 本文件每次都读。另外只读输入指定的那一份 `format-*.md` 和一份 `structure-*.md`, 不要凭记忆套用没加载的套路。 @@ -11,11 +11,11 @@ ## 一、合法组合表 -| | 痛点解决 | 前后对比 | 测评验证 | 场景种草 | -| ---- | :----: | :----: | :----: | :----: | -| **口播** | ✅ 强 | ✅ 可 | ✅ **最强** | ✅ 可 | -| **短剧** | ✅ **最强** | ✅ 强 | ❌ **禁用** | ✅ 可 | -| **Vlog** | ✅ 可 | ✅ 可 | ✅ 强 | ✅ **最强** | +| | 痛点解决 | 前后对比 | 测评验证 | 场景种草 | 促销抢购 | 知识分享 | +| ---- | :----: | :----: | :----: | :----: | :----: | :----: | +| **口播** | ✅ 强 | ✅ 可 | ✅ **最强** | ✅ 可 | ✅ **强** | ✅ **强** | +| **短剧** | ✅ **最强** | ✅ 强 | ❌ **禁用** | ✅ 可 | ✅ 可 | ✅ 可 | +| **Vlog** | ✅ 可 | ✅ 可 | ✅ 强 | ✅ **最强** | ✅ 可 | ✅ 可 | **唯一禁用组合:短剧 × 测评验证。** 理由:测评的说服力来自「这是真实发生的」,短剧的本质是「这是演的」。 @@ -48,7 +48,7 @@ 最弱的一格,因为口播是「对镜说话」,天然打断场景沉浸。 - **必须弱化对镜**:人在场景里自然活动,偶尔看向镜头,不要全程盯着 - 台词改成自言自语的语气,不要介绍腔 -- **每镜仍要说满 36–47 字**:写具体时刻、动作、使用感,不要一句商品名就收 +- **每镜仍要说满 66–74 字**:写具体时刻、动作、使用感,不要一句商品名就收 - 商品名全片点名 1–2 次,其余镜用卖点原词和场景细节 - 如果商品本身氛围属性强(香氛、饮品),优先考虑改用 Vlog @@ -94,6 +94,18 @@ - 商品在两到三个时间节点重复出现,靠**重复**建立记忆 - 至少一个镜头让商品外形被看清,这是本组合唯一的硬要求 +### 促销抢购(口播优先) +优惠机制清晰时,口播最容易把“适合谁、怎么买、买哪个规格”说清。 +- 只讲商品资料中明确的价格、优惠、库存、赠品、开售时间与领取条件 +- 没有优惠事实时不做抢购氛围,改用商品价值与使用场景 +- 结尾只给一个入口和一个操作动作,避免多重跳转 + +### 知识分享(口播优先) +口播负责讲判断方法,画面用商品细节和正确/错误示范来证明。 +- 先讲用户能复述的一个判断标准,再出现商品 +- 知识结论必须由商品资料支持;不能编数据、专业身份或功效 +- 30 秒起步,才有空间完成“误区 → 方法 → 演示 → 商品承接” + --- ## 三、组合与时长的关系 @@ -106,5 +118,7 @@ | 前后对比 | 15 秒 | 前后对比至少要撑满一镜 | | 测评验证 | 30 秒 | 举证需要时间,1 镜证据不足 | | 场景种草 | 30 秒 | 氛围需要铺陈,太短像随机片段 | +| 促销抢购 | 15 秒 | 优惠机制已清楚时,可压进适合谁 + 怎么买 + CTA | +| 知识分享 | 30 秒 | 判断方法和实物演示都需要时间 | 低于该结构的最短时长时,优先保住「钩子 + 结构核心 + CTA」三件,其余全砍。 diff --git a/core/backend/skills/ecommerce-video-script/references/playbooks/format-drama.md b/core/backend/skills/ecommerce-video-script/references/playbooks/format-drama.md index c80db04..e691062 100644 --- a/core/backend/skills/ecommerce-video-script/references/playbooks/format-drama.md +++ b/core/backend/skills/ecommerce-video-script/references/playbooks/format-drama.md @@ -53,7 +53,7 @@ - **单镜固定 15 秒**(短剧用镜内动作和对白交换撑满这 15 秒,不要靠切很多短镜) - 一镜一次冲突或一次对话交换,**别在一镜里塞完整三幕** -- 有对白的镜,每句 `line` ≤ 25 字(两个人说话,总量还是受 68 字限制) +- 有对白的镜,每句 `line` ≤ 25 字(两个人说话,总量还是受 78 字限制) **推荐总时长 45 秒(3 镜)。** 短剧需要装下三幕,低于 30 秒会讲不完整; 超过 60 秒在电商场景下性价比开始下降。 diff --git a/core/backend/skills/ecommerce-video-script/references/playbooks/format-oral.md b/core/backend/skills/ecommerce-video-script/references/playbooks/format-oral.md index 19d1616..3d985f8 100644 --- a/core/backend/skills/ecommerce-video-script/references/playbooks/format-oral.md +++ b/core/backend/skills/ecommerce-video-script/references/playbooks/format-oral.md @@ -52,7 +52,7 @@ - **单镜固定 15 秒**(口播里说完一个完整意思需要时间;15 秒靠景别切换和手部动作撑满,不要切碎) - 一句话讲一件事,**一镜 2–4 句短句**,禁止一句收工 - **visual 按秒拆 3–5 刀**,写成 `0-3s:近景……` / `3-8s:手持……` / `8-12s:特写……` / `12-15s:拉回……` -- 语速按 **3.6–4.5 字 / 秒** 估:15 秒镜 **54–67 字**,写作目标 **55–63 字** +- 语速按 **4.3–5.2 字 / 秒** 估:15 秒镜 **64–78 字**,写作目标 **66–74 字** **推荐总时长 30 秒(2 镜)。** 口播超过 45 秒完播率掉得很快,除非是测评验证这种 需要举证的结构。 diff --git a/core/backend/skills/ecommerce-video-script/references/playbooks/structure-knowledge.md b/core/backend/skills/ecommerce-video-script/references/playbooks/structure-knowledge.md new file mode 100644 index 0000000..e852348 --- /dev/null +++ b/core/backend/skills/ecommerce-video-script/references/playbooks/structure-knowledge.md @@ -0,0 +1,25 @@ +# 视频结构 · 知识分享 + +> 适合需要用户教育、选购判断、使用指导或原理说明的数码家电、美妆个护、食品饮料、服饰材质、家居用品和工具类商品。 + +## 一、结构骨架 + +``` +[ 常见问题 / 误区 ] → [ 一个判断标准 ] → [ 实物步骤或正反示范 ] → [ 商品自然承接 ] → [ 选择建议 + CTA ] +``` + +一条 30 秒视频只讲 1–3 个清晰知识点。商品不是突然插入的卖点,而是符合前述判断标准的具体实例。 + +## 二、写法 + +- 开头提出目标用户关心的问题、常见误区、实用技巧,或一个可验证的知识结论。 +- 用通俗语言解释原理;专业概念必须转换成用户能观察、能操作的判断方法。 +- 画面必须有实物细节、操作步骤、正确与错误示范或具体场景,不能全靠人物讲解。 +- 商品出现后,只说明它的材质、设计、功能或使用方式如何对应前面已经建立的判断标准。 +- 收尾总结适用条件或选择方法,再自然引导用户查看商品。 + +## 三、红线 + +- 不得编造数据、标准、专业身份或案例,也不得把一般知识偷换成商品功效承诺。 +- 健康、食品、美妆相关内容避免医疗、治疗及绝对化效果表述。 +- 当商品资料不足以支持某知识结论时,不要为了带货硬讲;改为可从商品实物观察到的使用或选购技巧。 diff --git a/core/backend/skills/ecommerce-video-script/references/playbooks/structure-promo.md b/core/backend/skills/ecommerce-video-script/references/playbooks/structure-promo.md new file mode 100644 index 0000000..298167e --- /dev/null +++ b/core/backend/skills/ecommerce-video-script/references/playbooks/structure-promo.md @@ -0,0 +1,24 @@ +# 视频结构 · 促销抢购 + +> 适合优惠机制清楚、库存可控、决策成本较低的商品,以及大促、新品拉新、福利款、组合装和阶段性促销。 + +## 一、结构骨架 + +``` +[ 适合谁 / 解决什么 ] → [ 商品与活动事实 ] → [ 怎么买最合适 ] → [ 明确入口与操作 ] +``` + +短时长时把「适合谁」与活动事实合并;结尾必须保留清楚的购买动作。 + +## 二、写法 + +- 先说清适合谁、什么使用场景或组合需求,不能一开场只喊“抢”。 +- 价格、优惠、赠品、库存、开售时间、适用人群和领取条件,只能使用输入商品资料里明确给出的事实。 +- 商品露出优先包装正面、规格/组合细节和真实使用场景;画面不能用伪造倒计时、销量或库存数字。 +- CTA 说明一个购买入口和一个操作,例如“从商品卡选择这个规格”;没有活动信息时,只引导“查看商品详情”。 + +## 三、红线 + +- 不得虚构原价、最低价、优惠幅度、活动时间、库存数量、赠品价值或“仅剩”。 +- 不得把普通价格写成限时、限量,也不得用低价引流到不同规格。 +- 没有明确优惠事实时,改用商品价值与适用场景说服,不制造抢购氛围。 diff --git a/core/backend/skills/ecommerce-video-script/references/playbooks/structure-scene.md b/core/backend/skills/ecommerce-video-script/references/playbooks/structure-scene.md index 8ac0299..b1fcbe4 100644 --- a/core/backend/skills/ecommerce-video-script/references/playbooks/structure-scene.md +++ b/core/backend/skills/ecommerce-video-script/references/playbooks/structure-scene.md @@ -81,7 +81,7 @@ - 语气是**自述或旁白**,轻、慢;留白靠**句间停顿和画面**,不靠少写字 - **不讲参数、不讲价格、不讲功效** —— 这些会立刻把观众从场景里踢出来 -- **口播 × 场景种草禁止整镜无声**:每镜仍要说满 36–47 字(2–4 句自述),像在场景里自言自语 +- **口播 × 场景种草禁止整镜无声**:每镜仍要说满 66–74 字(4–5 句自述),像在场景里自言自语 - **Vlog** 才可以个别镜无声,让环境音和画面说话 - CTA 要软:「链接放下面了」而不是「快去抢」 - 商品名全片点名 1–2 次,其余镜写时刻、动作、使用感、卖点原词 diff --git a/core/backend/skills/video-shot-digest/SKILL.md b/core/backend/skills/video-shot-digest/SKILL.md index 089a680..292f4d2 100644 --- a/core/backend/skills/video-shot-digest/SKILL.md +++ b/core/backend/skills/video-shot-digest/SKILL.md @@ -2,22 +2,22 @@ name: video-shot-digest description: > 上传视频提炼·分镜拆解领域技能(模型无关)。 - 服务对象不是人类,而是 AirShelf 产品后端的「上传视频提炼」入口——用户上传一条参考视频(多为已投放的电商带货成片), + 服务对象不是人类,而是 AirShelf 产品后端的「上传视频提炼 / 提炼提示词」入口——用户上传一条参考视频, 后端把**完整视频文件(含音轨)**喂给多模态模型,加载本技能作为系统提示词。 - 能力:看完整画面、听口播,还原这条视频的分镜——逐镜写清画面和解说词。 - 产出是**给人看、可人工逐镜修改的中文分镜稿**,随后由用户确认后交给脚本 agent 改写成自己商品的脚本。 + 能力:看完整画面、听口播与环境声,还原这条视频的分镜——逐镜写清景别、机位、运镜、画面、人物、台词、音效和字幕。 + 产出是**给人看、可人工逐镜修改的中文导演分镜稿**,随后由用户确认后交给脚本 agent 改写成自己商品的脚本。 当任务为「拆解参考视频、还原分镜、提炼视频结构、把视频变成可复用的脚本素材」时使用本技能。 --- # 参考视频 · 分镜拆解师 -你是一个**分镜拆解 agent**。输入是**一条电商带货短视频的完整文件**,包含画面和口播音轨。 -你的任务是**还原这条视频的分镜结构**,产出一份**中文分镜稿**。 +你是一个**分镜拆解 agent**。输入是**一条短视频的完整文件**,包含画面和音轨。 +你的任务是**还原这条视频的分镜结构**,产出一份**中文导演分镜稿**。 这份稿子有两个去处,缺一不可: -1. **给用户看、给用户改** —— 用户会逐镜校对你的拆解。必须逐镜分段、编号清楚、说人话。 -2. **喂给下游脚本 agent** —— 用户确认后,这份稿子会连同他自己的商品一起交给脚本 agent。每一镜要把画面动作和解说词写清楚。 +1. **给用户看、给用户改** —— 用户会逐镜校对。必须编号清楚、栏位齐全、说人话。 +2. **喂给下游脚本 agent** —— 用户确认后,这份稿子会连同他自己的商品一起交给脚本 agent。每一镜要把景别、运镜、动作、台词和声音写清楚,下游才能复用节奏,而不是只剩一句画面摘要。 > **模型无关声明**:本技能不依赖任何特定模型的能力或语气。无论运行在豆包 / GPT / Gemini / Claude 上,规则一致。 @@ -26,52 +26,79 @@ description: > ## 铁律(优先级最高) ### 铁律 1 · 只写你真看见、真听见的 -- **禁止编造**没有画面或声音依据的内容:编造的商品名、编造的品牌、编造的口播。 -- 口播听不清就写「听不清」,画面看不清就写「看不清」。宁可承认缺失,也不要编一个具体的。 +- **禁止编造**没有画面或声音依据的内容:编造的商品名、品牌、口播、音效、BGM。 +- 口播听不清就写「听不清」,画面看不清就写「看不清」,人物脸看不清就写「不可见」。没有就写「无」。宁可承认缺失,也不要编一个具体的。 - 不要评价(「拍得很好」「节奏很棒」)。你是拆解,不是影评。 -### 铁律 2 · 口播按听到的写 -你拿得到音轨。`解说词` 一栏写**口播原文**,逐字转写,不要润色、不要概括成「强调结实」。 -- 这一镜没有人说话:写画面上的花字 / 字幕;都没有就写「无」。 -- 不要写 BGM、音效、语气、音量。 +### 铁律 2 · 声音按听到的写 +你拿得到音轨。 +- `台词/旁白`:口播或对白**原文**,逐字转写,不要润色、不要概括。 +- `字幕`:画面上的花字 / 字幕**逐字照抄**;没有就写「无」。 +- `音效`:雨声、脚步、开瓶、汽车经过等你确实听见的同期声。 +- `背景音乐`:听得出的风格即可(如「低沉钢琴」「轻快电子」);听不出旋律名不要瞎编曲名。没有配乐就写「无」。 ### 铁律 3 · 覆盖全片,宁多勿少 用户要的是**整条片子的分镜稿**,不是片头摘要,也不是三幕概括。 -- 从 **0 秒写到片尾**。`【整体结构】` 里的时长必须接近输入给出的时长。 -- **有几镜写几镜**。口播带货片常见 6~20 镜;每换一个动作、一个卖点、一句新口播,就另起一镜。 +- 从 **00:00 写到片尾**。片头 `时长` 必须接近输入给出的时长。 +- **有几镜写几镜**。口播带货片常见 6~20 镜;每换一个动作、一个卖点、一句新口播、一次明显切镜,就另起一镜。 - **禁止**把全片压成「开头 / 中间 / 结尾」三大段,禁止只写前几镜就停。 -- 花字和字幕**逐字照抄**进画面描述,不要改写成「强调功效」。 --- ## 输出格式(严格照抄这个骨架,纯文本,不要 JSON、不要代码块、不要 markdown 表格) ``` -【整体结构】 +片名:《从画面和文件名判断的短标题》 +视频类型:口播带货 / 剧情短片 / Vlog / 看不出 +画面比例:9:16 竖屏 / 16:9 横屏 / 1:1 / 看不出 +时长:15秒 +整体风格:光线、色调、质感用三到六个词写清,例如「写实电影感、冷色调、压抑安静」 形式:口播 / 短剧 / Vlog(三选一,看不出就写「看不出」) 结构:痛点解决 / 前后对比 / 测评验证 / 场景种草(四选一,看不出就写「看不出」) -时长:约 N 秒 · 共 M 镜 主线:一句话说清这条片子从头到尾讲了什么 -【第 1 镜】0-3 秒 · 钩子 -画面:谁在哪做什么,商品怎么出现,景别和动作写进这一句 -解说词:口播原文。没有口播就写花字/字幕,都没有写「无」 +【镜头 01】 +时间:00:00-00:04 +时长:4秒 +景别:城市远景 / 全景 / 中景 / 中近景 / 近景 / 特写 +机位:高位俯拍 / 平视 / 人物侧后方 / 看不清 +运镜:缓慢向前推进 / 低速跟拍 / 固定 / 手持微晃 +画面:深夜街道被雨水覆盖,路灯倒映在积水中,一名黑衣女孩独自走过空旷街口。 +人物动作:女孩低头行走,右手撑着黑伞。没有人物写「无」。 +人物表情:警觉、疑惑。看不见脸写「不可见」。 +台词/旁白:是谁? 没有人声写「无」。 +音效:雨声、远处汽车经过声。 +背景音乐:低沉而克制的钢琴音。 +字幕:是谁? 没有花字写「无」。 +备注:冷蓝色灯光,路面反光明显。没有补充写「无」。 -【第 2 镜】3-8 秒 · 卖点 -画面:… -解说词:… +【镜头 02】 +时间:00:04-00:08 +时长:4秒 +景别:中近景 +机位:人物侧后方 +运镜:低速跟拍 +画面:镜头跟随女孩前进,她的外套和头发被风吹动。 +人物动作:女孩突然放慢脚步,微微转头。 +人物表情:警觉、疑惑。 +台词/旁白:是谁? +音效:脚步声停止,雨声短暂增强。 +背景音乐:钢琴停止,加入低频氛围音。 +字幕:是谁? +备注:强调人物停步的一瞬间。 -…(有几镜写几镜) +…(有几镜写几镜,镜号两位,01、02、03…) 【拆解存疑】 - 逐条列出你不确定的地方,让用户重点校对 ``` ### 格式硬要求 -- 镜号连续,从 1 开始。 -- 时间区间写成「0-3 秒」这种整数闭区间。 -- 每镜标题里的作用只能从**钩子 / 痛点 / 卖点 / 证明 / 转化 / 过渡**里选一个词。 -- 每镜只要 **画面 + 解说词** 两行,不要再拆成主体/运镜/光线等七栏——栏太多会写不完整。 +- 镜号写成 `【镜头 01】` 这种两位数字,从 01 连续递增。 +- `时间` 用 `00:00-00:04` 这种分:秒闭区间,必须接上上一镜结束点,不能跳秒、不能重叠。 +- `时长` 必须等于该镜时间区间的秒数。 +- 上面列出的栏位**每一镜都要有**,不要省略;没有内容就写「无」或「不可见」。 - **`【拆解存疑】`一节必须有**,哪怕只有一条。 - 全文中文,不出现 markdown 标题符号(`#`)和代码块围栏。 +- 不要把一镜压成「画面 + 解说词」两行。栏位太少,下游脚本就只能得到一句摘要。 diff --git a/core/frontend/src/App.tsx b/core/frontend/src/App.tsx index 5c00b75..ee829f2 100644 --- a/core/frontend/src/App.tsx +++ b/core/frontend/src/App.tsx @@ -45,7 +45,7 @@ import { TeamPage } from "./routes"; import type { AuthMode, NavigateOptions, Notice, Page, ResolvedRoute } from "./routes/route-config"; -import { isOwnerOnlyPage, parentPage, pathForPage, resolveRoute } from "./routes/route-config"; +import { isOwnerOnlyPage, isPage, parentPage, pathForPage, resolveRoute, routeLabels } from "./routes/route-config"; import { AdminApp } from "./routes/admin/admin-app"; import { TrashPage } from "./routes/trash"; import { ModelsPage } from "./routes/models"; @@ -522,8 +522,8 @@ export function App() { setNotice({ type: "info", text: "当前账号暂无访问权限" }); return; } - // 图片创作只有显式入口才携带商品;从图片生成首页进入时不能继承全局当前商品。 - const productId = next === "imageOptimize" ? options.productId : (options.productId ?? activeProductId); + // 图片创作 / 极速成片只有显式入口才携带商品;从工作台或视频创作进入时不能继承全局当前商品。 + const productId = next === "imageOptimize" || next === "quickCreate" ? options.productId : (options.productId ?? activeProductId); const projectId = options.projectId ?? activeProjectId; if (options.productId !== undefined) setActiveProductId(options.productId); if (options.projectId !== undefined) setActiveProjectId(options.projectId); @@ -566,8 +566,14 @@ export function App() { window.scrollTo({ top: 0, behavior: "auto" }); } + function entryOrigin(fallback: Page = parentPage(page)): Page { + const fromPage = readNavState(window.history.state)?.from?.page; + return fromPage && isPage(fromPage) ? fromPage : fallback; + } + function goBack(fallback: Page = parentPage(page)) { - if (readNavState(window.history.state)?.from?.page) { + const from = readNavState(window.history.state)?.from; + if (from?.page && isPage(from.page)) { window.history.back(); return; } @@ -900,7 +906,7 @@ export function App() { ); case "productCreateUpload": // 设计稿:新建商品是商品库页上的右侧 Drawer(非独立整页),进入即自动打开 - // 创建成功后由 ProductsPage 弹「继续创建商品 / 去新建项目」选择弹窗,不再自动跳详情 + // 创建成功后由 ProductsPage 弹「继续创建商品 / 极速成片 / 专业创作」选择弹窗,不再自动跳详情 return ( setNotice({ type, text })} onTaskSettled={refreshFreeCreateShell} onBack={() => goBack("projects")} />; case "quickCreate": - return goBack("projects")} />; + return ( + goBack(entryOrigin("projects"))} + backLabel={`返回${routeLabels[entryOrigin("projects")]}`} + initialProductId={route.productId} + navigate={navigate} + modelConfigs={modelConfigs} + onNotify={(type, text) => setNotice({ type, text })} + onProjectCreated={() => { void loadData(); }} + /> + ); case "videoRemix": return m.capability === "text" && m.status === "active")} onNotify={(type, text) => setNotice({ type, text })} onBack={() => goBack("projects")} navigate={navigate} />; case "imageOptimize": diff --git a/core/frontend/src/api.ts b/core/frontend/src/api.ts index 1329761..4068ba8 100644 --- a/core/frontend/src/api.ts +++ b/core/frontend/src/api.ts @@ -43,6 +43,7 @@ import type { TeamMember, User, UserPreference, + VideoDigestHistory, VoiceoverInfo } from "./types"; import type { PresentationFormat, VideoStructure } from "./script-setup"; @@ -59,6 +60,51 @@ const FRIENDLY_AUTH_MISSING_MESSAGE = "登录状态异常,请刷新页面后 export type RememberInfo = { username: string; expireAt: number }; +export type QuickCreateJob = { + id: string; + project_id: string; + product_id: string; + product_name: string; + title: string; + product_images: Array<{ asset_id: string; url: string }>; + status: "queued" | "running" | "succeeded" | "failed" | "cancelled"; + phase: "product" | "script" | "assets" | "production" | "complete"; + phase_index: number; + progress: number; + message: string; + error_message: string; + settings: { + aspect_ratio: string; + resolution: string; + total_duration: number; + video_model_config_id: string; + video_model_name: string; + video_model_label: string; + }; + result: null | { + video_url: string; + poster_url: string; + duration_seconds: number; + aspect_ratio: string; + resolution: string; + video_model: string; + structure: string; + presentation: string; + person: string; + scene: string; + video_segments: Array<{ + id: string; + sort_order: number; + duration_seconds: number; + video_url: string; + poster_url: string; + }>; + }; + created_at: string; + updated_at: string; + completed_at: string | null; +}; + export function getRemember(): RememberInfo | null { try { const raw = localStorage.getItem(REMEMBER_KEY); @@ -317,6 +363,18 @@ export const api = { createProject(payload: { name: string; product: string; metadata?: Record }) { return request("/api/projects/", { method: "POST", body: JSON.stringify(payload) }); }, + startQuickCreate(formData: FormData) { + return request("/api/projects/quick-create/", { method: "POST", body: formData }); + }, + quickCreateStatus(jobId: string) { + return request(`/api/projects/quick-create-status/${jobId}/`); + }, + cancelQuickCreate(jobId: string) { + return request(`/api/projects/quick-create-cancel/${jobId}/`, { method: "POST" }); + }, + quickCreateHistory() { + return request<{ count: number; results: QuickCreateJob[] }>("/api/projects/quick-create-history/"); + }, // 整体替换 metadata —— 调用方务必先展开现有 project.metadata 再合并,别把别的 key 冲掉 updateProject(id: string, payload: { name?: string; metadata?: Record }) { return request(`/api/projects/${id}/`, { method: "PATCH", body: JSON.stringify(payload) }); @@ -350,11 +408,37 @@ export const api = { }, // 视频提炼页:不绑项目,整段视频交给 Gemini 出中文分镜稿。慢(约 1–2 分钟),固定 30 积分,失败退还。 extractVideoDigest(formData: FormData) { - return request<{ name: string; chars: number; text: string; frames: number; duration: number; input?: string; estimated_cost?: string }>( + return request<{ + name: string; + chars: number; + text: string; + frames: number; + duration: number; + input?: string; + estimated_cost?: string; + task_id?: string; + title?: string; + file_name?: string; + ratio?: string; + shots?: number; + cover_url?: string; + video_url?: string; + width?: number; + height?: number; + }>( "/api/ai/video-digest/", { method: "POST", body: formData } ); }, + listVideoDigests() { + return request<{ results: VideoDigestHistory[]; total: number }>("/api/ai/video-digest/"); + }, + saveVideoDigest(id: string, prompt: string) { + return request(`/api/ai/video-digest/${id}/`, { + method: "PATCH", + body: JSON.stringify({ prompt }), + }); + }, // ── 套路模板(5.1 存 / 5.2 换商品重跑)· 团队隔离 ── // 存模板只抽套路不抽文案:后端从这版脚本抽镜数/每镜作用/节奏/商品露出/转化写法。 saveProjectAsTemplate(projectId: string, payload: { name: string; script_version_id?: string }) { diff --git a/core/frontend/src/components/app-shell.tsx b/core/frontend/src/components/app-shell.tsx index abb70e6..f4407c9 100644 --- a/core/frontend/src/components/app-shell.tsx +++ b/core/frontend/src/components/app-shell.tsx @@ -16,6 +16,7 @@ const SHELL_COMMANDS: Command[] = [ { id: "projects", group: "导航", label: "视频创作", sub: "从商品或参考视频出发,选择生产方式", page: "projects", icon: "clapperboard", key: "V" }, { id: "asset-factory", group: "导航", label: "图片生成", sub: "模特上身图、平台套图、图片创作", page: "assetFactory", icon: "sparkles", key: "I" }, { id: "free-create", group: "导航", label: "自由创作", sub: "AI 视频生成 · 全能参考 / 首尾帧", page: "freeCreate", icon: "film", key: "F" }, + { id: "quick-create", group: "导航", label: "极速成片", sub: "上传商品图片,自动完成整条视频", page: "quickCreate", icon: "wand", key: "Q" }, { id: "video-remix", group: "导航", label: "提炼提示词", sub: "上传参考视频,提炼可编辑提示词", page: "videoRemix", icon: "scan", key: "R" }, { id: "library", group: "导航", label: "成品库", sub: "素材、人物、场景、成片统一管理", page: "library", icon: "folder", key: "A" }, { id: "team", group: "导航", label: "团队", sub: "成员、权限、额度、协作记录", page: "team", icon: "users" }, @@ -24,6 +25,7 @@ const SHELL_COMMANDS: Command[] = [ { id: "messages", group: "常用动作", label: "消息中心", sub: "任务提醒、协作评论、系统通知", page: "messages", icon: "bell", key: "M" }, { id: "new-product", group: "常用动作", label: "新建商品", sub: "从商品信息开始生成素材与视频", page: "productCreateUpload", icon: "productPlus" }, { id: "new-project", group: "常用动作", label: "新建视频项目", sub: "选择商品并进入脚本配置", page: "projectWizard", icon: "clapperboard" }, + { id: "quick-create-action", group: "常用动作", label: "极速成片", sub: "输入商品名称并上传图片,自动生成视频", page: "quickCreate", icon: "wand" }, { id: "model-photo", group: "常用动作", label: "生成模特上身图", sub: "快速生成 3:4 商品展示素材", page: "modelPhoto", icon: "users" }, { id: "platform-cover", group: "常用动作", label: "生成平台套图", sub: "适配电商平台封面与详情图", page: "platformCover", icon: "images" }, { id: "image-optimize", group: "常用动作", label: "图片创作", sub: "对话式生成、编辑", page: "imageOptimize", icon: "images" } @@ -392,6 +394,7 @@ const PAGE_TO_NAV: Partial> = { models: "models", projects: "projects", projectWizard: "projects", + quickCreate: "projects", pipeline: "projects", assetFactory: "assetFactory", imageOptimize: "assetFactory", diff --git a/core/frontend/src/model-display.ts b/core/frontend/src/model-display.ts index 891f579..3529ac6 100644 --- a/core/frontend/src/model-display.ts +++ b/core/frontend/src/model-display.ts @@ -24,3 +24,8 @@ export function publicModelDisplayName( if (!model) return fallback; return publicModelRouteName(model.name, model.display_name?.trim() || model.name || fallback); } + +/** Gemini 3.1 只给「提炼提示词」用,脚本助手下拉里不出现。 */ +export function isHiddenFromScriptPicker(model: Pick) { + return model.name === "gemini-3.1-pro-preview" || publicModelDisplayName(model) === "AirShelf Script"; +} diff --git a/core/frontend/src/pipeline-page.css b/core/frontend/src/pipeline-page.css index 859bdf4..1ef34b5 100644 --- a/core/frontend/src/pipeline-page.css +++ b/core/frontend/src/pipeline-page.css @@ -2120,3 +2120,19 @@ .tpl-capture .v { flex: 1; color: var(--accent-black); word-break: break-word; } .tpl-note { margin: 10px 0 0; font-size: 12px; line-height: 1.7; color: var(--black-alpha-56); } .tpl-note .mono { font-family: var(--font-mono); font-size: 10px; color: var(--heat); margin-right: 4px; } + +/* 脚本模型菜单挂到 body:输入卡 overflow:hidden 会裁掉向上展开的列表 */ +.chat-model-menu.chip-menu { + --klein: #002fa7; + position: fixed; + top: auto; + display: block; + z-index: 9999; +} +.chat-model-menu.chip-menu .mi.selected { + color: var(--klein) !important; + background: rgba(0, 47, 167, 0.10) !important; +} +.chat-model-menu.chip-menu .mi.selected .mi-check { + color: var(--klein) !important; +} diff --git a/core/frontend/src/quick-create-page.css b/core/frontend/src/quick-create-page.css index d95c8cf..8d41997 100644 --- a/core/frontend/src/quick-create-page.css +++ b/core/frontend/src/quick-create-page.css @@ -1,37 +1,117 @@ /* 极速成片:按用户提供的「影擎」页面逐项转写,仅作用于该页面。 */ -.quick-create-page { --quick-blue: #002fa7; --quick-blue-hover: #002680; --quick-muted: #6f747c; width: min(1560px,100%); min-height: calc(100vh - 164px); margin: -24px auto -60px; padding: 52px 0 48px; } +.quick-create-page { --quick-blue: #002fa7; --quick-blue-hover: #002680; --quick-muted: #6f747c; width: min(1560px,100%); min-height: calc(100vh - 164px); margin: -24px auto -60px; padding: 59px 0 48px; } .quick-create-page .project-builder-header { display: flex; align-items: center; justify-content: space-between; gap: 24px; margin-bottom: 20px; } .quick-create-page .project-builder-title { display: flex; align-items: center; gap: 14px; } .quick-create-page .project-builder-title h1 { margin: 0 0 5px; color: #17181a; font-size: 28px; line-height: 1.2; font-weight: 700; } -.quick-create-page .project-builder-title p { margin: 0; color: var(--quick-muted); font-size: 13px; } -.quick-create-page .project-builder-status { display: inline-flex; align-items: center; gap: 8px; padding: 9px 13px; border: 1px solid rgba(34,42,54,.09); border-radius: 999px; color: var(--quick-muted); background: rgba(255,255,255,.76); font-size: 12px; } +.quick-create-page .project-builder-title p { margin: 0; color: var(--quick-muted); font-size: 13px; line-height: 18px; } +.quick-create-page .project-builder-status { display: inline-flex; align-items: center; gap: 8px; margin-right: 5px; padding: 9px 15.6px; border: 1px solid rgba(34,42,54,.09); border-radius: 999px; color: var(--quick-muted); background: rgba(255,255,255,.76); font-size: 12px; line-height: 18px; } .quick-create-page .project-builder-status strong { color: var(--quick-blue); font-size: 13px; } .quick-create-page .image-back-button { width: 38px; height: 38px; display: grid; place-items: center; flex: 0 0 auto; margin-top: 2px; border: 1px solid rgba(0,47,167,.46); border-radius: 12px; color: var(--quick-blue); background: rgba(255,255,255,.82); cursor: pointer; box-shadow: 0 5px 14px rgba(0,47,167,.07); transition: background-color 180ms ease, transform 180ms ease; } .quick-create-page .image-back-button:hover { transform: translateX(-2px); border-color: var(--quick-blue); background: rgba(0,47,167,.055); }.quick-create-page .image-back-button svg { width: 19px; height: 19px; } -.quick-create-header { margin-bottom: 22px !important; } +.quick-create-header { margin-bottom: 16px !important; } .quick-create-page .quick-create-shell { min-height: clamp(650px, calc(100vh - 266px), 850px); display: grid; grid-template-columns: minmax(0, .92fr) minmax(0, 1.08fr); gap: 22px; } .quick-create-page .quick-create-panel { min-width: 0; overflow: hidden; border: 1px solid rgba(34,42,54,.10); border-radius: 18px; background: rgba(255,255,255,.88); box-shadow: 0 16px 40px rgba(20,27,38,.09); } -.quick-create-page .quick-form-panel { display: flex; flex-direction: column; padding: 30px; } -.quick-create-page .quick-form-copy h2 { margin: 0 0 8px; font-size: 26px; } +.quick-create-page .quick-form-panel { display: flex; flex-direction: column; padding: 25px 30px 30px; border-top: 3px solid var(--quick-blue); } +.quick-create-page .quick-status-panel { border-top: 3px solid #111216; } +.quick-create-page .quick-form-copy h2 { margin: 0; font-size: 26px; } .quick-create-page .quick-form-copy p { margin: 0; color: var(--quick-muted); font-size: 13px; line-height: 1.7; } -.quick-create-page .quick-field { display: grid; gap: 9px; margin-top: 24px; } +.quick-create-page .quick-field { display: grid; gap: 9px; margin-top: 20px; } +.quick-create-page .quick-form-copy + .quick-field { margin-top: 17px; } .quick-create-page .quick-field-label { display: flex; align-items: center; justify-content: space-between; gap: 14px; font-size: 14px; font-weight: 700; } .quick-create-page .quick-field-label small { color: var(--quick-muted); font-size: 11px; font-weight: 500; } .quick-create-page .quick-name-input { height: 50px; padding: 0 15px; border: 1px solid rgba(34,42,54,.13); border-radius: 12px; outline: none; background: rgba(248,249,252,.88); transition: border-color 180ms ease, box-shadow 180ms ease, background 180ms ease; } .quick-create-page .quick-name-input:focus { border-color: rgba(0,47,167,.55); background: #fff; box-shadow: 0 0 0 4px rgba(0,47,167,.08); } -.quick-create-page .quick-upload { position: relative; min-height: 190px; display: grid; place-items: center; overflow: hidden; border: 1px dashed rgba(0,47,167,.28); border-radius: 14px; background: radial-gradient(circle at center,rgba(0,47,167,.07),transparent 47%),rgba(248,249,252,.86); cursor: pointer; } +.quick-create-page .quick-upload { position: relative; min-height: 190px; display: grid; place-items: center; overflow: hidden; border: 1px dashed rgba(0,47,167,.28); border-radius: 14px; background: radial-gradient(circle at center,rgba(0,47,167,.07),transparent 47%),rgba(248,249,252,.86); } .quick-create-page .quick-upload input { position: absolute; width: 1px; height: 1px; opacity: 0; pointer-events: none; } -.quick-create-page .quick-upload-copy { display: grid; justify-items: center; gap: 8px; padding: 24px; text-align: center; } +.quick-create-page .quick-upload-copy { display: grid; justify-items: center; gap: 8px; padding: 24px; border: 0; color: inherit; background: transparent; font: inherit; text-align: center; cursor: pointer; } .quick-create-page .quick-upload-icon { width: 40px; height: 40px; display: grid; place-items: center; color: var(--quick-blue); } .quick-create-page .quick-upload-icon svg { width: 34px; height: 34px; stroke-width: 1.75; } .quick-create-page .quick-upload-copy strong { font-size: 15px; }.quick-create-page .quick-upload-copy small { color: var(--quick-muted); font-size: 11px; } -.quick-create-page .quick-upload-preview { position: absolute; inset: 0; display: none; background: #f5f6f9; }.quick-create-page .quick-upload.has-image .quick-upload-copy { display: none; }.quick-create-page .quick-upload.has-image .quick-upload-preview { display: block; } -.quick-create-page .quick-upload-preview img { width: 100%; height: 100%; object-fit: cover; }.quick-create-page .quick-image-count { position: absolute; right: 12px; bottom: 12px; padding: 6px 9px; border-radius: 999px; color: #fff; background: rgba(16,16,18,.72); font-size: 11px; } -.quick-create-page .quick-image-clear { position: absolute; top: 12px; right: 12px; width: 34px; height: 34px; display: grid; place-items: center; border: 0; border-radius: 10px; color: #fff; background: rgba(16,16,18,.72); cursor: pointer; }.quick-create-page .quick-image-clear svg { width: 16px; height: 16px; } -.quick-create-page .quick-auto-note { display: grid; grid-template-columns: repeat(4,minmax(0,1fr)); gap: 8px; margin-top: 20px; }.quick-create-page .quick-auto-note span { min-height: 54px; display: grid; place-items: center; padding: 8px; border-radius: 10px; color: #4f5662; background: rgba(34,42,54,.045); text-align: center; font-size: 11px; line-height: 1.45; } -.quick-create-page .quick-form-footer { display: flex; align-items: center; justify-content: space-between; gap: 20px; margin-top: auto; padding-top: 26px; }.quick-create-page .quick-cost strong,.quick-create-page .quick-cost span { display: block; }.quick-create-page .quick-cost span { color: var(--quick-muted); font-size: 11px; }.quick-create-page .quick-cost strong { margin-top: 5px; font-size: 14px; } -.quick-create-page .quick-generate-button { min-width: 220px; height: 48px; display: inline-flex; align-items: center; justify-content: center; gap: 9px; border: 0; border-radius: 12px; color: #fff; background: var(--quick-blue); font: inherit; font-weight: 700; cursor: pointer; box-shadow: 0 12px 24px rgba(0,47,167,.22); }.quick-create-page .quick-generate-button:disabled { color: #99a1ae; background: #e5e8ee; box-shadow: none; cursor: not-allowed; }.quick-create-page .quick-generate-button svg { width: 20px; height: 20px; } -.quick-create-page .quick-status-panel { position: relative; display: grid; place-items: center; min-height: 0; padding: 34px; background: radial-gradient(circle at 50% 45%,rgba(0,47,167,.075),transparent 34%),rgba(250,251,253,.88); }.quick-create-page .quick-state { width: min(560px,100%); }.quick-create-page .quick-state-ready { display: grid; justify-items: center; text-align: center; } +.quick-create-page .quick-upload.has-images { min-height: 268px; padding: 14px; background: rgba(248,249,252,.86); } +.quick-create-page .quick-upload-filled { width: min(100%,492px); display: grid; grid-template-columns: 240px 240px; gap: 12px; } +.quick-create-page .quick-image-grid { display: grid; grid-template-columns: repeat(3,minmax(0,1fr)); grid-template-rows: repeat(3,minmax(0,1fr)); gap: 6px; padding: 8px; border-radius: 13px; background: rgba(255,255,255,.92); } +.quick-create-page .quick-image-tile { position: relative; min-width: 0; aspect-ratio: 1; overflow: hidden; border: 1px dashed rgba(0,47,167,.15); border-radius: 8px; background: rgba(255,255,255,.48); } +.quick-create-page .quick-image-tile img { width: 100%; height: 100%; display: block; object-fit: cover; } +.quick-create-page .quick-image-tile button { position: absolute; top: 3px; right: 3px; width: 19px; height: 19px; display: grid; place-items: center; padding: 0; border: 0; border-radius: 50%; color: #fff; background: rgba(25,28,34,.72); cursor: pointer; }.quick-create-page .quick-image-tile button svg { width: 12px; height: 12px; } +.quick-create-page .quick-upload-more { min-height: 240px; display: grid; place-items: center; align-content: center; gap: 8px; border: 1px dashed rgba(0,47,167,.35); border-radius: 13px; background: rgba(243,247,255,.78); } +.quick-create-page .quick-upload-trigger { display: grid; justify-items: center; gap: 8px; padding: 0; border: 0; color: #303746; background: transparent; font: inherit; cursor: pointer; }.quick-create-page .quick-upload-trigger strong { font-size: 14px; font-weight: 700; }.quick-create-page .quick-upload-trigger small { color: var(--quick-muted); font-size: 12px; } +.quick-create-page .quick-upload-more-icon { width: 40px; height: 40px; display: grid; place-items: center; border-radius: 12px; color: var(--quick-blue); background: #fff; box-shadow: 0 5px 16px rgba(0,47,167,.08); }.quick-create-page .quick-upload-more-icon svg { width: 24px; height: 24px; stroke-width: 1.75; } +.quick-create-page .quick-clear-all { padding: 5px 9px; border: 0; border-radius: 7px; color: var(--quick-muted); background: rgba(255,255,255,.72); font: inherit; font-size: 11px; cursor: pointer; } +.quick-create-page .quick-parameter-grid { display: grid; grid-template-columns: repeat(2,minmax(0,1fr)); gap: 12px; margin-top: 20px; } +.quick-create-page .quick-parameter-field { min-width: 0; display: grid; gap: 9px; padding: 14px; border: 1px solid rgba(34,42,54,.10); border-radius: 12px; background: rgba(248,249,252,.72); } +.quick-create-page .quick-parameter-field > span { color: #30343a; font-size: 12px; font-weight: 700; } +.quick-create-page .quick-parameter-field select { width: 100%; height: 38px; padding: 0 36px 0 13px; border: 1px solid rgba(34,42,54,.13); border-radius: 10px; outline: none; color: #272a30; background-color: rgba(255,255,255,.92); font: inherit; font-size: 12px; cursor: pointer; } +.quick-create-page .quick-parameter-field select:focus { border-color: rgba(0,47,167,.55); box-shadow: 0 0 0 3px rgba(0,47,167,.08); } +.quick-create-page .quick-parameter-field select:disabled { cursor: not-allowed; opacity: .65; } +.quick-create-page .quick-form-footer { display: flex; margin-top: 26px; } +.quick-create-page .quick-generate-button { width: 100%; height: 48px; display: inline-flex; align-items: center; justify-content: center; gap: 9px; border: 0; border-radius: 12px; color: #fff; background: var(--quick-blue); font: inherit; font-weight: 700; cursor: pointer; box-shadow: 0 12px 24px rgba(0,47,167,.22); }.quick-create-page .quick-generate-button:disabled { color: #99a1ae; background: #e5e8ee; box-shadow: none; cursor: not-allowed; }.quick-create-page .quick-generate-button svg { width: 20px; height: 20px; } +.quick-create-page .quick-cancel-button { width: 100%; height: 48px; display: inline-flex; align-items: center; justify-content: center; gap: 9px; border: 1px solid rgba(34,42,54,.16); border-radius: 12px; color: #272a30; background: #fff; font: inherit; font-weight: 700; cursor: pointer; }.quick-create-page .quick-cancel-button:hover:not(:disabled) { border-color: rgba(200,61,77,.35); color: #c83d4d; background: rgba(200,61,77,.06); }.quick-create-page .quick-cancel-button:disabled { opacity: .55; cursor: not-allowed; }.quick-create-page .quick-cancel-button svg { width: 18px; height: 18px; } +.quick-create-page .quick-create-shell.is-generating .quick-form-panel { opacity: .82; } +.quick-create-page .quick-create-shell.is-generating .quick-form-footer { pointer-events: auto; opacity: 1; } +.quick-create-page .quick-status-panel { position: relative; display: grid; place-items: center; min-height: 0; padding: 34px; background: radial-gradient(circle at 50% 45%,rgba(0,47,167,.075),transparent 34%),rgba(250,251,253,.88); }.quick-create-page .quick-state { width: min(560px,100%); display: none; }.quick-create-page .quick-state-ready { display: grid; justify-items: center; text-align: center; } +.quick-create-page .quick-create-shell.is-generating .quick-state-ready,.quick-create-page .quick-create-shell.is-complete .quick-state-ready,.quick-create-page .quick-create-shell.is-failed .quick-state-ready { display: none; } +.quick-create-page .quick-create-shell.is-generating .quick-state-generating { display: grid; } +.quick-create-page .quick-create-shell.is-complete .quick-state-complete { display: grid; } +.quick-create-page .quick-create-shell.is-failed .quick-state-failed { display: grid; } .quick-create-page .quick-ready-orbit { position: relative; width: 176px; height: 176px; display: grid; place-items: center; margin-bottom: 24px; border: 1px solid rgba(0,47,167,.14); border-radius: 50%; }.quick-create-page .quick-ready-orbit::before,.quick-create-page .quick-ready-orbit::after { content: ""; position: absolute; border: 1px solid rgba(0,47,167,.07); border-radius: 50%; }.quick-create-page .quick-ready-orbit::before { inset: 20px; }.quick-create-page .quick-ready-orbit::after { inset: -28px; }.quick-create-page .quick-ready-icon { width: 54px; height: 54px; display: grid; place-items: center; color: var(--quick-blue); }.quick-create-page .quick-ready-icon svg { width: 42px; height: 42px; stroke-width: 1.7; } +.quick-create-page .quick-state-ready { position: relative; top: -10px; } .quick-create-page .quick-state-ready h2 { margin: 0 0 9px; font-size: 23px; }.quick-create-page .quick-state-ready > p { max-width: 430px; margin: 0; color: var(--quick-muted); font-size: 13px; line-height: 1.7; }.quick-create-page .quick-ready-tags { display: flex; flex-wrap: wrap; justify-content: center; gap: 8px; margin-top: 20px; }.quick-create-page .quick-ready-tags span { padding: 7px 10px; border: 1px solid rgba(34,42,54,.08); border-radius: 999px; color: #505762; background: rgba(255,255,255,.72); font-size: 11px; } +.quick-create-page .quick-state-generating { width: min(460px,100%); justify-items: center; text-align: center; } +.quick-create-page .quick-generating-preview { position: relative; width: 460px; max-width: 100%; height: 258px; display: grid; place-items: center; margin-bottom: 27px; border: 1px solid rgba(0,47,167,.16); border-radius: 15px; background: #edf4ff; box-shadow: 0 18px 34px rgba(0,47,167,.08); }.quick-create-page .quick-generating-preview::before { content: ""; position: absolute; inset: 18px; border-radius: 10px; background: #fff; } +.quick-create-page .quick-preview-spinner { position: relative; z-index: 1; width: 62px; height: 62px; border-radius: 50%; background: conic-gradient(from 160deg,transparent 0deg,transparent 136deg,var(--quick-blue) 300deg,rgba(0,47,167,.12) 360deg); -webkit-mask: radial-gradient(circle,transparent 0 66%,#000 69%); mask: radial-gradient(circle,transparent 0 66%,#000 69%); animation: quick-spinner-rotate 1.1s linear infinite; } +.quick-create-page .quick-generating-copy { width: 100%; text-align: center; }.quick-create-page .quick-generating-copy h2 { margin: 0 0 8px; font-size: 23px; }.quick-create-page .quick-generating-copy p { margin: 0; color: var(--quick-muted); font-size: 13px; line-height: 1.7; } +.quick-create-page .quick-progress-track { width: 100%; display: flex; align-items: flex-start; margin-top: 28px; }.quick-create-page .quick-progress-node { position: relative; z-index: 0; flex: 1 1 0; display: grid; justify-items: center; color: #9aa1ab; font-size: 12px; }.quick-create-page .quick-progress-node:not(:last-child)::after { content: ""; position: absolute; z-index: -1; top: 23px; left: 50%; width: 100%; height: 2px; background: rgba(34,42,54,.12); }.quick-create-page .quick-progress-node.done:not(:last-child)::after { background: var(--quick-blue); }.quick-create-page .quick-progress-dot { width: 46px; height: 46px; display: grid; place-items: center; border: 5px solid rgba(0,47,167,.10); border-radius: 50%; color: #fff; background: var(--quick-blue); }.quick-create-page .quick-progress-dot svg { width: 18px; height: 18px; stroke-width: 1.8; }.quick-create-page .quick-progress-node strong { margin-top: 9px; max-width: 8em; text-align: center; font-size: 12px; font-weight: 700; line-height: 1.35; }.quick-create-page .quick-progress-node.done,.quick-create-page .quick-progress-node.active { color: var(--quick-blue); }.quick-create-page .quick-progress-node.active { color: #16171a; }.quick-create-page .quick-progress-node.active .quick-progress-dot { border-color: rgba(22,23,26,.12); background: #16171a; } +.quick-create-page .quick-generating-actions { display: flex; justify-content: center; margin-top: 22px; }.quick-create-page .quick-generating-actions button { min-width: 132px; min-height: 44px; display: inline-flex; align-items: center; justify-content: center; gap: 7px; }.quick-create-page .quick-generating-actions svg { width: 17px; height: 17px; } +.quick-create-page .quick-state-complete { width: min(560px,100%); gap: 17px; } +.quick-create-page .quick-video-result-grid { display: grid; grid-template-columns: repeat(2,minmax(0,1fr)); gap: 12px; } +.quick-create-page .quick-video-result-grid.is-single { grid-template-columns: minmax(0, 1fr); width: min(320px,100%); justify-self: center; } +.quick-create-page .quick-video-result-card { min-width: 0; padding: 8px 8px 9px; border: 1px solid rgba(34,42,54,.10); border-radius: 8px; color: #25282d; background: #fff; font: inherit; text-align: left; cursor: pointer; } +.quick-create-page .quick-video-result-thumb { position: relative; display: block; aspect-ratio: 16/9; overflow: hidden; border-radius: 8px; background: #eef1f5; } +.quick-create-page .quick-video-result-thumb img,.quick-create-page .quick-video-result-thumb video { width: 100%; height: 100%; display: block; object-fit: cover; } +.quick-create-page .quick-video-play { position: absolute; inset: 0; display: grid; place-items: center; color: #fff; background: rgba(0,0,0,.22); } +.quick-create-page .quick-video-play svg { width: 42px; height: 42px; fill: currentColor; stroke: currentColor; stroke-width: 1.2; filter: drop-shadow(0 2px 8px rgba(0,0,0,.28)); } +.quick-create-page .quick-video-result-meta { display: flex; align-items: center; justify-content: space-between; gap: 8px; padding: 8px 3px 0; } +.quick-create-page .quick-video-result-meta strong { font-size: 12px; font-weight: 600; } +.quick-create-page .quick-video-result-meta small { color: var(--quick-muted); font-size: 11px; } +.quick-create-page .quick-result-head { display: flex; align-items: center; justify-content: space-between; gap: 16px; } +.quick-create-page .quick-result-head h2,.quick-create-page .quick-result-head p { margin: 0; }.quick-create-page .quick-result-head h2 { font-size: 22px; }.quick-create-page .quick-result-head p { margin-top: 5px; color: var(--quick-muted); font-size: 11px; } +.quick-create-page .quick-result-badge { padding: 7px 10px; border-radius: 999px; color: #168d43; background: rgba(22,184,78,.10); font-size: 11px; font-weight: 700; white-space: nowrap; } +.quick-create-page .quick-result-actions { display: grid; grid-template-columns: auto auto minmax(160px,1fr); gap: 10px; } +.quick-create-page .quick-result-actions.is-single { grid-template-columns: auto minmax(160px,1fr); }.quick-create-page .quick-result-actions button,.quick-create-page .quick-result-actions a { min-height: 48px; display: inline-flex; align-items: center; justify-content: center; gap: 7px; text-decoration: none; }.quick-create-page .quick-result-actions svg { width: 17px; height: 17px; } +.quick-create-page .primary-action,.quick-create-page .secondary-action { min-height: 44px; padding: 0 15px; border-radius: 8px; font: inherit; font-size: 12px; font-weight: 600; cursor: pointer; transition: background-color 180ms ease,border-color 180ms ease,color 180ms ease; } +.quick-create-page .primary-action { border: 1px solid var(--quick-blue); color: #fff; background: var(--quick-blue); }.quick-create-page .primary-action:hover:not(:disabled) { border-color: var(--quick-blue-hover); background: var(--quick-blue-hover); }.quick-create-page .primary-action:disabled { opacity: .45; cursor: not-allowed; } +.quick-create-page .secondary-action { border: 1px solid rgba(34,42,54,.12); color: #272a30; background: rgba(255,255,255,.78); }.quick-create-page .secondary-action:hover:not(:disabled) { color: var(--quick-blue); border-color: rgba(0,47,167,.24); background: rgba(0,47,167,.05); } +.quick-create-page .quick-download-action { border-radius: 8px; } +.quick-create-page .quick-state-failed { justify-items: center; gap: 14px; text-align: center; }.quick-create-page .quick-state-failed h2,.quick-create-page .quick-state-failed p { margin: 0; }.quick-create-page .quick-state-failed p { max-width: 430px; color: var(--quick-muted); font-size: 13px; line-height: 1.7; }.quick-create-page .quick-failed-icon { width: 82px; height: 82px; display: grid; place-items: center; border-radius: 50%; color: #c83d4d; background: rgba(200,61,77,.08); }.quick-create-page .quick-failed-icon svg { width: 36px; height: 36px; }.quick-create-page .quick-failed-actions { display: flex; flex-wrap: wrap; justify-content: center; gap: 10px; margin-top: 8px; }.quick-create-page .quick-failed-actions button { min-height: 44px; display: inline-flex; align-items: center; justify-content: center; gap: 7px; }.quick-create-page .quick-failed-actions svg { width: 17px; height: 17px; } +@keyframes quick-spinner-rotate { to { transform: rotate(360deg); } } +.quick-create-page .quick-history { margin-top: 28px; } +.quick-create-page .quick-history-head { display: flex; align-items: baseline; justify-content: space-between; gap: 16px; margin-bottom: 12px; } +.quick-create-page .quick-history-head h2 { margin: 0; font-size: 20px; font-weight: 600; } +.quick-create-page .quick-history-head span { color: var(--quick-muted); font-size: 13px; } +.quick-create-page .quick-history-list { display: grid; gap: 10px; } +.quick-create-page .quick-history-card { display: grid; grid-template-columns: 112px minmax(0,1fr) auto; align-items: center; gap: 16px; padding: 12px 14px; border: 1px solid rgba(34,42,54,.10); border-radius: 8px; background: #fff; box-shadow: inset 3px 0 0 var(--quick-blue); } +.quick-create-page .quick-history-thumb { position: relative; width: 112px; aspect-ratio: 16/9; overflow: hidden; padding: 0; border: 0; border-radius: 8px; background: #eef1f5; cursor: pointer; } +.quick-create-page .quick-history-thumb img { width: 100%; height: 100%; display: block; object-fit: cover; } +.quick-create-page .quick-history-thumb-empty { width: 100%; height: 100%; display: grid; place-items: center; color: var(--quick-blue); } +.quick-create-page .quick-history-thumb-empty svg { width: 18px; height: 18px; } +.quick-create-page .quick-history-thumb small { position: absolute; right: 6px; bottom: 6px; padding: 1px 6px; border-radius: 4px; color: #fff; background: rgba(0,0,0,.62); font-size: 10px; line-height: 16px; } +.quick-create-page .quick-history-copy { min-width: 0; } +.quick-create-page .quick-history-badge { display: inline-flex; padding: 2px 8px; border-radius: 999px; color: var(--quick-blue); background: rgba(0,47,167,.08); font-size: 11px; font-weight: 600; } +.quick-create-page .quick-history-copy h3 { margin: 6px 0 4px; font-size: 16px; font-weight: 600; } +.quick-create-page .quick-history-copy p { margin: 0; color: var(--quick-muted); font-size: 12px; } +.quick-create-page .quick-history-open { min-height: 36px; display: inline-flex; align-items: center; gap: 6px; padding: 0 12px; border: 1px solid rgba(34,42,54,.12); border-radius: 8px; color: var(--quick-blue); background: #fff; font: inherit; font-size: 13px; cursor: pointer; } +.quick-create-page .quick-history-open:hover { background: rgba(0,47,167,.05); } +.quick-create-page .quick-history-open svg { width: 15px; height: 15px; } +.quick-create-page .quick-history-empty { margin: 0; padding: 28px 8px; color: var(--quick-muted); font-size: 13px; } +.quick-create-page .quick-player-bg { position: fixed; inset: 0; z-index: 80; display: grid; place-items: center; padding: 24px; background: rgba(22,23,26,.58); } +.quick-create-page .quick-player { width: min(920px,100%); overflow: hidden; border-radius: 8px; background: #111216; } +.quick-create-page .quick-player-bar { display: flex; align-items: center; justify-content: space-between; gap: 12px; padding: 10px 12px; color: #fff; } +.quick-create-page .quick-player-bar strong { font-size: 13px; font-weight: 600; } +.quick-create-page .quick-player-bar button { width: 32px; height: 32px; display: grid; place-items: center; border: 0; border-radius: 8px; color: #fff; background: transparent; cursor: pointer; } +.quick-create-page .quick-player-bar svg { width: 16px; height: 16px; } +.quick-create-page .quick-player video { width: 100%; max-height: min(72vh, 620px); display: block; background: #000; } +@media (max-width: 980px) { .quick-create-page .quick-history-card { grid-template-columns: 96px minmax(0,1fr); }.quick-create-page .quick-history-open { grid-column: 1 / -1; justify-self: start; } } +@media (min-width: 1753px) { .quick-create-page { position: relative; left: -21.5px; } } @media (max-width: 1400px) { .quick-create-page .quick-create-shell { grid-template-columns: minmax(0,.9fr) minmax(0,1.1fr); }.quick-create-page .quick-form-panel,.quick-create-page .quick-status-panel { padding: 24px; } } +@media (max-width: 980px) { .quick-create-page { padding-inline: 18px; }.quick-create-page .quick-create-shell { grid-template-columns: 1fr; }.quick-create-page .quick-create-panel { min-height: 620px; }.quick-create-page .quick-result-actions { grid-template-columns: 1fr; } } +@media (max-width: 620px) { .quick-create-page .quick-parameter-grid { grid-template-columns: 1fr; }.quick-create-page .quick-upload-filled { width: min(100%,240px); grid-template-columns: 1fr; }.quick-create-page .quick-upload-more { min-height: 148px; } } diff --git a/core/frontend/src/routes/dashboard.tsx b/core/frontend/src/routes/dashboard.tsx index c8a969e..90863be 100644 --- a/core/frontend/src/routes/dashboard.tsx +++ b/core/frontend/src/routes/dashboard.tsx @@ -72,9 +72,16 @@ function dashStageLabel(project: Project): string { }; return map[project.current_stage] || "视频生成"; } +function isQuickCreateProject(project: Project) { + if (project.quick_create) return true; + if (project.metadata?.quick_create) return true; + return / · 极速成片$/.test(project.name || ""); +} + function dashCardMeta(project: Project, productTitle: string): string { const shots = project.video_segment_count ?? project.video_segments?.length ?? 0; - return ["专业创作", productTitle, shots ? `${shots} 镜` : null].filter(Boolean).join(" / "); + const mode = isQuickCreateProject(project) ? "极速成片" : "专业创作"; + return [mode, productTitle, shots ? `${shots} 镜` : null].filter(Boolean).join(" / "); } function EntryIcon({ name }: { name: "wand" | "folder" | "scan" | "replace" }) { diff --git a/core/frontend/src/routes/pipeline.tsx b/core/frontend/src/routes/pipeline.tsx index e920517..2150a65 100644 --- a/core/frontend/src/routes/pipeline.tsx +++ b/core/frontend/src/routes/pipeline.tsx @@ -4,7 +4,7 @@ import type { ChangeEvent, CSSProperties, PointerEvent as ReactPointerEvent } fr import { ArrowLeft, ArrowRight, Check, ChevronRight, Image, Info, LayoutList, Play, RefreshCw, Route, Sparkles, Upload, UsersRound, X } from "lucide-react"; import { api, ApiError } from "../api"; import type { Asset, BillingSummary, ExportPoll, ModelConfig, Product, Project, StoryboardShot, Team, TimelineSavePayload, User } from "../types"; -import { publicModelDisplayName } from "../model-display"; +import { isHiddenFromScriptPicker, publicModelDisplayName } from "../model-display"; import { isPublicGenerationError, presentGenerationError } from "../generation-error"; import type { Notice, Page } from "./route-config"; import { stageOrder, statusPill } from "./stage-config"; @@ -516,7 +516,8 @@ export function PipelinePage(props: { logout: () => void; onNotify?: (type: "success" | "error", text: string) => void; scriptModelName: string; - textModels?: ModelConfig[]; onAdoptScript: (scriptId: string) => void | Promise; + textModels?: ModelConfig[]; + onAdoptScript: (scriptId: string) => void | Promise; onUpdateShot: (payload: { segment_id: string; narration?: string; visual_prompt?: string; duration_seconds?: number }) => Promise; onAddShot: (afterSegmentId: string, content?: { narration?: string; visual_prompt?: string }) => Promise; onDeleteShot: (segmentId: string) => Promise; @@ -1399,18 +1400,53 @@ export function PipelinePage(props: { } catch { /* localStorage 不可用则忽略 */ } }, [chatKey, chatMsgs]); const pushMsg = (role: "ai" | "user", text: string) => setChatMsgs((list) => [...list, { id: nextMsgId(), role, text, time: nowHm() }]); - // 脚本模型下拉:用户可选 豆包/GPT-5.5/Gemini(空 = 用后端默认文本模型) + // 脚本模型下拉:用户可选 豆包/GPT 等;Gemini 3.1(AirShelf Script)只给视频提炼,不出现在这里 const [scriptModelId, setScriptModelId] = useState(""); - const activeScriptModelId = scriptModelId || textModels?.[0]?.id || ""; + const scriptPickerModels = useMemo( + () => (textModels || []).filter((model) => !isHiddenFromScriptPicker(model)), + [textModels], + ); + const activeScriptModelId = ( + scriptPickerModels.some((model) => model.id === scriptModelId) ? scriptModelId : "" + ) || scriptPickerModels[0]?.id || ""; // 模型选择小按钮(输入框下方)· 自建 restraint 下拉(幽灵触发 + popover 菜单),不再用原生 select + inline const [modelMenuOpen, setModelMenuOpen] = useState(false); - const activeModel = textModels?.find((m) => m.id === activeScriptModelId); + const [modelMenuPos, setModelMenuPos] = useState<{ left: number; bottom: number; width: number } | null>(null); + const modelPickRef = useRef(null); + const activeModel = scriptPickerModels.find((m) => m.id === activeScriptModelId); const activeModelName = publicModelDisplayName(activeModel); useEffect(() => { - if (!modelMenuOpen) return; - const close = (e: MouseEvent) => { if (!(e.target as HTMLElement).closest(".chat-model-pick")) setModelMenuOpen(false); }; + if (!modelMenuOpen) { + setModelMenuPos(null); + return; + } + const place = () => { + const trigger = modelPickRef.current?.querySelector("button.chip"); + if (!(trigger instanceof HTMLElement)) return; + const rect = trigger.getBoundingClientRect(); + setModelMenuPos({ + left: Math.min(rect.left, window.innerWidth - Math.max(200, rect.width) - 8), + bottom: window.innerHeight - rect.top + 4, + width: Math.max(200, rect.width), + }); + }; + place(); + const close = (event: MouseEvent) => { + const target = event.target as HTMLElement; + if (target.closest(".chat-model-pick") || target.closest(".chat-model-menu")) return; + setModelMenuOpen(false); + }; + const onKey = (event: KeyboardEvent) => { if (event.key === "Escape") setModelMenuOpen(false); }; document.addEventListener("click", close); - return () => document.removeEventListener("click", close); + document.addEventListener("keydown", onKey); + window.addEventListener("resize", place); + window.addEventListener("scroll", place, true); + return () => { + document.removeEventListener("click", close); + document.removeEventListener("keydown", onKey); + window.removeEventListener("resize", place); + window.removeEventListener("scroll", place, true); + }; }, [modelMenuOpen]); // 删除分镜的两步确认(行内变红,3 秒不二次点击自动复位;不用原生 confirm) const [armedDelete, setArmedDelete] = useState(null); @@ -1660,9 +1696,10 @@ export function PipelinePage(props: { setChatText(""); // 上传视频提炼:参考的是**结构与节奏**,画面里的商品是别人的,必须换成用户自己的商品重写 await runScriptGeneration( - `${base}\n以上是我拆解一条参考视频得到的分镜稿。请照搬它的叙事结构、镜头节奏和每镜的作用顺序,` - + `把内容整体换成我自己的商品重写一遍;参考视频里出现的商品、品牌、人物一律不要保留。` - + `原稿里标注「看不清 / 缺失 / 存疑」的地方,由你按我的商品补全。目标人群:${personaLabel}`, + `${base}\n以上是我拆解一条参考视频得到的分镜稿。请照搬它的镜头顺序、每镜时长比例、景别、机位、运镜、人物动作和声音层次,` + + `把人物、商品、品牌、台词全部换成我自己的商品;参考视频里出现的商品、品牌、人物一律不要保留。` + + `写 visual 时把每镜的景别、机位、运镜、动作、表情、音效、背景音乐、字幕、备注折进导演说明书,不要压成一句画面摘要。` + + `原稿里标注「无 / 不可见 / 听不清 / 存疑」的地方不要编造,由你按我的商品补全。目标人群:${personaLabel}`, `上传视频提炼 · ${combo}`, "video", ); @@ -1782,8 +1819,8 @@ export function PipelinePage(props: { pushMsg( "ai", digest.input === "native_video" - ? `已拆出 ${digest.duration} 秒整段视频。拆解稿在下面输入框里,请先逐镜核对再点确定 —— 尤其是「拆解存疑」那几条。确认后我按你的商品把它改写成新脚本。` - : `已拆出 ${digest.duration} 秒 · 取了 ${digest.frames} 帧。拆解稿在下面输入框里,请先逐镜核对再点确定 —— 尤其是「拆解存疑」那几条。确认后我按你的商品把它改写成新脚本。` + ? `已拆出 ${digest.duration} 秒整段视频。拆解稿在下面输入框里,请先逐镜核对景别、运镜、台词和声音再点确定。确认后我按你的商品改写脚本,镜头节奏会跟稿子对齐。` + : `已拆出 ${digest.duration} 秒 · 取了 ${digest.frames} 帧。拆解稿在下面输入框里,请先逐镜核对景别、运镜、台词和声音再点确定。确认后我按你的商品改写脚本,镜头节奏会跟稿子对齐。` ); chatTextareaRef.current?.focus(); } catch (error) { @@ -3195,22 +3232,30 @@ export function PipelinePage(props: { : } {/* 模型选择小按钮(输入框下方,对齐 ChatGPT/Lovart)· 复用 restraint chip 下拉,向上展开 */} - {textModels && textModels.length > 0 ? ( -
- -
- {textModels.map((m) => ( -
{ setScriptModelId(m.id); setModelMenuOpen(false); }} - onKeyDown={(e) => { if (e.key === "Enter" || e.key === " ") { e.preventDefault(); setScriptModelId(m.id); setModelMenuOpen(false); } }}> - {publicModelDisplayName(m)} - -
- ))} -
+ {modelMenuOpen && modelMenuPos && createPortal( +
+ {scriptPickerModels.map((m) => ( +
{ setScriptModelId(m.id); setModelMenuOpen(false); }} + onKeyDown={(e) => { if (e.key === "Enter" || e.key === " ") { e.preventDefault(); setScriptModelId(m.id); setModelMenuOpen(false); } }}> + {publicModelDisplayName(m)} + +
+ ))} +
, + document.body, + )}
) : null} @@ -3876,9 +3921,9 @@ export function PipelinePage(props: { )} {/* AI 生成片段不需单卡上传(自定义替换走 queue-bar 全局上传);移除多余「上传」按钮 */} - {url - ? 下载 - : } + {url && !showBusy ? ( + 下载 + ) : null} diff --git a/core/frontend/src/routes/products.tsx b/core/frontend/src/routes/products.tsx index 59e6127..8cfced4 100644 --- a/core/frontend/src/routes/products.tsx +++ b/core/frontend/src/routes/products.tsx @@ -21,7 +21,7 @@ const PROD_PAGE_SIZE = 10; // 商品详情「AI 素材」grid 每页条数(4 列 → 3 整行) const MAT_PAGE_SIZE = 12; import type { Asset, Product, ProductMaterials, Project } from "../types"; -import type { NavigateFn, Page } from "./route-config"; +import type { NavigateFn } from "./route-config"; import "../product-create-page.css"; const PC_PHOTO_SLOTS = ["主图", "细节 02", "细节 03", "细节 04", "细节 05"]; @@ -87,7 +87,7 @@ export function ProductsPage({ products, projects = [], loading = false, navigat products: Product[]; projects?: Project[]; loading?: boolean; - navigate: (page: Page) => void; + navigate: NavigateFn; openProduct: (productId: string, tab?: "assets" | "videos") => void; onCreate: (payload: ProductPayload) => Promise | void; onUploadImage?: (productId: string, formData: FormData) => Promise | void; @@ -132,8 +132,8 @@ export function ProductsPage({ products, projects = [], loading = false, navigat } }; const [drawer, setDrawer] = useState(Boolean(autoOpenCreate)); - // 创建成功弹窗:存刚创建的商品名,非空即展示「继续创建 / 去新建项目」选择 - const [createdName, setCreatedName] = useState(null); + // 创建成功弹窗:存刚创建的商品,非空即展示「继续创建 / 极速成片 / 专业创作」 + const [createdProduct, setCreatedProduct] = useState(null); const [openChip, setOpenChip] = useState<"" | "cat" | "date" | "type">(""); // 商品分类筛选改回多选:Set 存已选分类(空 = 全部),菜单含每项计数 + chip 上橙色计数徽标 const [catFilter, setCatFilter] = useState>(new Set()); @@ -351,14 +351,15 @@ export function ProductsPage({ products, projects = [], loading = false, navigat /> setCreatedName(null)} + detail={`「${createdProduct?.title}」已加入商品库。你可以继续创建商品,或直接为它生成视频。`} + close={() => setCreatedProduct(null)} actions={ <> - - + + + } /> @@ -369,8 +370,8 @@ export function ProductsPage({ products, projects = [], loading = false, navigat close={() => setDrawer(false)} onCreate={onCreate} onUploadImage={onUploadImage} - // 创建成功 → 弹「继续创建商品 / 去新建项目」选择弹窗(替代纯 toast) - onCreated={(product) => setCreatedName(product.title)} + // 创建成功 → 弹「继续创建商品 / 极速成片 / 专业创作」选择弹窗(替代纯 toast) + onCreated={(product) => setCreatedProduct(product)} /> ); @@ -1208,10 +1209,14 @@ export function ProductDetailPage({ product, projects, initialTab = "assets", na
视频生成
-
-
navigate("projectWizard", { productId: product.id })}> +
+
navigate("quickCreate", { productId: product.id })}> + + 极速成片 +
+
navigate("projectWizard", { productId: product.id })}> - 生成视频 + 专业创作
diff --git a/core/frontend/src/routes/projects.tsx b/core/frontend/src/routes/projects.tsx index 68cdc53..327b407 100644 --- a/core/frontend/src/routes/projects.tsx +++ b/core/frontend/src/routes/projects.tsx @@ -435,7 +435,7 @@ const PROJ_TABS: Array<{ filter: "all" | "draft" | "wip" | "done" | "fail"; labe type ProjTab = (typeof PROJ_TABS)[number]["filter"]; const VIDEO_MAKE: Array<{ title: string; desc: string; page: Page; image: string; primary?: boolean }> = [ - { title: "极速成片", desc: "输入商品名称并上传图片,自动完成脚本、场景、故事板和视频生成。", page: "projectWizard", image: "/assets/yz/video-quick.jpg", primary: true }, + { title: "极速成片", desc: "输入商品名称并上传图片,自动完成脚本、场景、故事板和视频生成。", page: "quickCreate", image: "/assets/yz/video-quick.jpg", primary: true }, { title: "专业创作", desc: "逐步控制脚本结构、商品与人物资产、故事板和最终视频。", page: "projectWizard", image: "/assets/yz/video-pro.jpg", primary: true }, ]; const VIDEO_TOOLS: Array<{ title: string; desc: string; page: Page; image: string }> = [ @@ -444,13 +444,24 @@ const VIDEO_TOOLS: Array<{ title: string; desc: string; page: Page; image: strin { title: "视频复刻", desc: "保留参考视频的人物、场景与节奏,复刻出自己的带货内容。", page: "freeCreate", image: "/assets/yz/video-replace.jpg" }, ]; +function isQuickCreateProject(project: Project) { + if (project.quick_create) return true; + if (project.metadata?.quick_create) return true; + return / · 极速成片$/.test(project.name || ""); +} + +function projectModeLabel(project: Project) { + return isQuickCreateProject(project) ? "极速成片" : "专业创作"; +} + function projCardSub(project: Project, productTitle: string): string { if (project.status === "failed") return project.failure_reason || "生成失败,可重新拍摄"; + const mode = projectModeLabel(project); const shots = projShotMeta(project); const stage = projStageLabel(project); - if (project.status === "completed") return `专业创作 · ${productTitle} · ${shots}`; + if (project.status === "completed") return `${mode} · ${productTitle} · ${shots}`; const no = projStageNo(project); - return `专业创作 · ${stage} · ${no} / ${PROJ_STAGE_TOTAL}`; + return `${mode} · ${stage} · ${no} / ${PROJ_STAGE_TOTAL}`; } function projStageLabel(project: Project): string { if (project.status === "failed") return "失败"; @@ -741,7 +752,7 @@ export function ProjectsPage({ products, projects, loading = false, navigate, op

{projCardSub(project, productTitle(project.product))}

{statusLabel} - 专业创作 + {projectModeLabel(project)}
{bucket === "wip" && ( diff --git a/core/frontend/src/routes/quick-create.tsx b/core/frontend/src/routes/quick-create.tsx index c2b0a0f..83e9fc2 100644 --- a/core/frontend/src/routes/quick-create.tsx +++ b/core/frontend/src/routes/quick-create.tsx @@ -1,78 +1,649 @@ -import { useEffect, useState } from "react"; -import { ArrowLeft, Sparkles, Trash2, Upload, WandSparkles } from "lucide-react"; -import type { Page } from "./route-config"; +import { useEffect, useMemo, useRef, useState } from "react"; +import { + AlertCircle, + ArrowLeft, + Clapperboard, + Download, + ImagePlus, + RefreshCw, + ScanSearch, + ScrollText, + SlidersHorizontal, + Sparkles, + Play, + Upload, + UsersRound, + WandSparkles, + X, + Columns2, + ArrowUpRight, +} from "lucide-react"; +import { api, ApiError, type QuickCreateJob } from "../api"; +import type { ModelConfig } from "../types"; +import { + DEFAULT_BILLING_RATES, + estimateCost, + FC_MODELS, + modelLabel, + type BillingRates, +} from "../components/free-create/constants"; +import type { NavigateFn } from "./route-config"; -export function QuickCreatePage({ onBack }: { onBack: () => void; navigate?: (page: Page) => void }) { +const QUICK_JOB_KEY = "airshelf:quick-create-job"; +const PROGRESS_STEPS = [ + { label: "识别商品与卖点", icon: ScanSearch }, + { label: "推荐脚本方向", icon: ScrollText }, + { label: "匹配模特与场景", icon: UsersRound }, + { label: "生成故事板与视频", icon: Clapperboard }, +]; +const QUICK_RATIOS = [ + { value: "9:16", label: "9:16 竖屏" }, + { value: "16:9", label: "16:9 横屏" }, + { value: "1:1", label: "1:1 方形" }, + { value: "3:4", label: "3:4 竖版" }, + { value: "4:3", label: "4:3 横版" }, + { value: "21:9", label: "21:9 超宽" }, +]; +const QUICK_RESOLUTIONS = [ + { value: "480p", label: "480p 流畅" }, + { value: "720p", label: "720p 高清" }, + { value: "1080p", label: "1080p 超清" }, + { value: "4k", label: "4K 超清" }, +]; +const QUICK_DURATIONS = [15, 30, 45, 60]; + +function modelResolutions(config: ModelConfig | undefined) { + const capabilities = (config?.metadata?.capabilities || {}) as Record; + const nested = Array.isArray(capabilities.resolutions) ? capabilities.resolutions : []; + const legacy = Array.isArray(config?.metadata?.resolutions) ? config.metadata.resolutions : []; + return (nested.length ? nested : legacy).map(String); +} + +function formatClock(seconds: number) { + const total = Math.max(0, Math.round(Number(seconds) || 0)); + const minutes = Math.floor(total / 60); + const rest = total % 60; + return `${String(minutes).padStart(2, "0")}:${String(rest).padStart(2, "0")}`; +} + +function historyTitle(item: QuickCreateJob) { + return (item.title || item.product_name || "极速成片").replace(/ · 极速成片$/, ""); +} + +function savedJobId() { + try { + return localStorage.getItem(QUICK_JOB_KEY) || ""; + } catch { + return ""; + } +} + +export function QuickCreatePage({ + onBack, + backLabel = "返回视频创作", + initialProductId, + navigate, + onNotify, + onProjectCreated, + modelConfigs, +}: { + onBack: () => void; + backLabel?: string; + initialProductId?: string; + navigate: NavigateFn; + onNotify?: (type: "success" | "error" | "info", text: string) => void; + onProjectCreated?: () => void; + modelConfigs: ModelConfig[]; +}) { const [name, setName] = useState(""); const [images, setImages] = useState([]); + const [savedImages, setSavedImages] = useState>([]); + const [sourceProductId, setSourceProductId] = useState(""); const [preview, setPreview] = useState(""); + const [imagePreviews, setImagePreviews] = useState([]); + const [jobId, setJobId] = useState(savedJobId); + const [job, setJob] = useState(null); + const [submitting, setSubmitting] = useState(false); + const [cancelling, setCancelling] = useState(false); + const [serviceUnavailable, setServiceUnavailable] = useState(false); + const [unavailableMessage, setUnavailableMessage] = useState(""); + const [history, setHistory] = useState([]); + const [playing, setPlaying] = useState<{ url: string; poster: string; title: string } | null>(null); + const videoConfigs = useMemo( + () => modelConfigs.filter((config) => config.capability === "video" && config.status === "active"), + [modelConfigs], + ); + const preferredModel = useMemo( + () => videoConfigs.find((config) => config.name === FC_MODELS[1].name) || videoConfigs[0], + [videoConfigs], + ); + const [aspectRatio, setAspectRatio] = useState("9:16"); + const [resolution, setResolution] = useState("720p"); + const [totalDuration, setTotalDuration] = useState(15); + const [videoModelId, setVideoModelId] = useState(""); + const [billingRates, setBillingRates] = useState(DEFAULT_BILLING_RATES); + const completedNoticeRef = useRef(""); + const cancelRequestedRef = useRef(false); + const notifyRef = useRef(onNotify); + const projectCreatedRef = useRef(onProjectCreated); + const imageInputRef = useRef(null); useEffect(() => { - if (!images[0]) { - setPreview(""); + notifyRef.current = onNotify; + projectCreatedRef.current = onProjectCreated; + }, [onNotify, onProjectCreated]); + + useEffect(() => { + if (!videoModelId && preferredModel) setVideoModelId(preferredModel.id); + }, [preferredModel, videoModelId]); + + useEffect(() => { + void api.billingConfig() + .then((config) => setBillingRates({ + margin: Number(config.video_margin_multiplier) || DEFAULT_BILLING_RATES.margin, + rate: Number(config.points_per_yuan) || DEFAULT_BILLING_RATES.rate, + multiplier: Number(config.team_price_multiplier) || 1, + })) + .catch(() => undefined); + void api.quickCreateHistory() + .then((payload) => setHistory(payload.results || [])) + .catch(() => undefined); + }, []); + + useEffect(() => { + if (!initialProductId || jobId) return; + let cancelled = false; + void api.product(initialProductId) + .then((product) => { + if (cancelled) return; + setName((current) => current || product.title || ""); + setSourceProductId((current) => current || product.id); + setSavedImages((current) => { + if (current.length) return current; + return (product.images || []) + .filter((image) => image.asset) + .slice(0, 9) + .map((image) => ({ asset_id: image.asset, url: image.preview_url || "" })); + }); + }) + .catch(() => undefined); + return () => { + cancelled = true; + }; + }, [initialProductId, jobId]); + + const selectedVideoModel = useMemo( + () => videoConfigs.find((config) => config.id === videoModelId) || preferredModel, + [preferredModel, videoConfigs, videoModelId], + ); + const supportedResolutions = useMemo( + () => modelResolutions(selectedVideoModel), + [selectedVideoModel], + ); + + useEffect(() => { + if (!supportedResolutions.length || supportedResolutions.includes(resolution)) return; + setResolution(supportedResolutions.includes("720p") ? "720p" : supportedResolutions[0]); + }, [resolution, supportedResolutions]); + + useEffect(() => { + if (!images.length) { + setImagePreviews([]); return; } - const url = URL.createObjectURL(images[0]); - setPreview(url); - return () => URL.revokeObjectURL(url); + const urls = images.map((image) => URL.createObjectURL(image)); + setImagePreviews(urls); + return () => urls.forEach((url) => URL.revokeObjectURL(url)); }, [images]); - function selectImages(files: FileList | null) { - setImages(Array.from(files || []).slice(0, 9)); + useEffect(() => { + setPreview(imagePreviews[0] || savedImages.find((image) => image.url)?.url || ""); + }, [imagePreviews, savedImages]); + + useEffect(() => { + if (!jobId) return; + let cancelled = false; + let timer = 0; + const poll = async () => { + try { + const next = await api.quickCreateStatus(jobId); + if (cancelled) return; + setJob(next); + if (next.product_name) setName((current) => current || next.product_name); + if (next.product_images?.length) { + setSavedImages(next.product_images.filter((image) => image.asset_id)); + if (next.product_id) setSourceProductId(next.product_id); + setImages([]); + } + if (next.settings) { + setAspectRatio(next.settings.aspect_ratio); + setResolution(next.settings.resolution); + setTotalDuration(next.settings.total_duration); + if (next.settings.video_model_config_id) setVideoModelId(next.settings.video_model_config_id); + } + if (next.status === "succeeded") { + if (completedNoticeRef.current !== next.id) { + completedNoticeRef.current = next.id; + notifyRef.current?.("success", "极速成片已生成"); + projectCreatedRef.current?.(); + void api.quickCreateHistory().then((payload) => setHistory(payload.results || [])).catch(() => undefined); + } + return; + } + if (next.status === "failed" || next.status === "cancelled") return; + timer = window.setTimeout(poll, 2500); + } catch (error) { + if (cancelled) return; + // 极速任务按团队隔离。本机切换账号后,localStorage 里可能仍保留上个团队的任务 ID; + // 404 不是生成失败,清掉这条旧记录即可,不能把“任务不存在”不断弹给新账号。 + if (error instanceof ApiError && error.status === 404) { + setJob(null); + setJobId(""); + try { + localStorage.removeItem(QUICK_JOB_KEY); + } catch { + /* ignore */ + } + notifyRef.current?.("info", "已清除其他账号的极速成片记录"); + return; + } + notifyRef.current?.("error", error instanceof Error ? error.message : "读取极速成片进度失败"); + timer = window.setTimeout(poll, 8000); + } + }; + void poll(); + return () => { + cancelled = true; + window.clearTimeout(timer); + }; + }, [jobId]); + + useEffect(() => { + if (!playing) return; + const onKey = (event: KeyboardEvent) => { + if (event.key === "Escape") setPlaying(null); + }; + window.addEventListener("keydown", onKey); + return () => window.removeEventListener("keydown", onKey); + }, [playing]); + + function playClip(url?: string, poster?: string, title?: string) { + if (!url) { + onNotify?.("info", "视频还不能播放,请稍后再试"); + return; + } + setPlaying({ url, poster: poster || "", title: title || "预览视频" }); } + function selectImages(files: FileList | null) { + const selected = Array.from(files || []).filter((file) => file.type.startsWith("image/")); + setImages((current) => { + const room = Math.max(0, 9 - savedImages.length - current.length); + const accepted = selected.slice(0, room); + if (selected.length > room) onNotify?.("info", "最多上传9张图片,已保留前9张"); + return [...current, ...accepted]; + }); + } + + function removeImage(index: number) { + if (index < savedImages.length) { + setSavedImages((current) => current.filter((_, imageIndex) => imageIndex !== index)); + return; + } + setImages((current) => current.filter((_, imageIndex) => imageIndex !== index - savedImages.length)); + } + + function clearImages() { + setSavedImages([]); + setImages([]); + } + + async function startGeneration() { + if (!name.trim() || (!images.length && !savedImages.length)) { + onNotify?.("info", "请先填写商品名称并上传商品图片"); + return; + } + setSubmitting(true); + setJob(null); + setServiceUnavailable(false); + setUnavailableMessage(""); + cancelRequestedRef.current = false; + try { + const form = new FormData(); + form.append("name", name.trim()); + form.append("aspect_ratio", aspectRatio); + form.append("resolution", resolution); + form.append("total_duration", String(totalDuration)); + if (videoModelId) form.append("video_model_config_id", videoModelId); + if (sourceProductId && savedImages.length) { + form.append("source_product_id", sourceProductId); + savedImages.forEach((image) => form.append("image_asset_ids", image.asset_id)); + } + images.forEach((image) => form.append("images", image)); + const created = await api.startQuickCreate(form); + if (cancelRequestedRef.current) { + try { + await api.cancelQuickCreate(created.id); + } catch { + /* 启动刚成功但用户已点取消时,尽量停掉后台任务 */ + } + resetResult(); + notifyRef.current?.("info", "已取消本次生成"); + return; + } + setJob(created); + setJobId(created.id); + setImages([]); + setSavedImages(created.product_images || []); + setSourceProductId(created.product_id || ""); + try { + localStorage.setItem(QUICK_JOB_KEY, created.id); + } catch { + /* 本地存储不可用时只影响刷新恢复,不影响本次生成 */ + } + onNotify?.("success", "极速成片任务已启动"); + onProjectCreated?.(); + } catch (error) { + if (error instanceof ApiError && error.status === 503) { + setServiceUnavailable(true); + setUnavailableMessage(error.message || "极速成片服务暂不可用,请稍后再试"); + onNotify?.("info", error.message || "极速成片服务暂不可用,请稍后再试"); + } else { + onNotify?.("error", error instanceof Error ? error.message : "极速成片启动失败"); + } + } finally { + setSubmitting(false); + } + } + + function resetResult() { + setJob(null); + setJobId(""); + setCancelling(false); + setServiceUnavailable(false); + setUnavailableMessage(""); + completedNoticeRef.current = ""; + try { + localStorage.removeItem(QUICK_JOB_KEY); + } catch { + /* ignore */ + } + } + + async function cancelGeneration() { + if (cancelling) return; + cancelRequestedRef.current = true; + if (!jobId) { + setSubmitting(false); + resetResult(); + notifyRef.current?.("info", "已取消本次生成"); + return; + } + setCancelling(true); + try { + const next = await api.cancelQuickCreate(jobId); + setJob(next); + notifyRef.current?.("info", "已取消本次生成"); + } catch (error) { + if (error instanceof ApiError && error.status === 404) { + resetResult(); + notifyRef.current?.("info", "已退出本次生成"); + return; + } + // 接口失败也不能把人锁在转圈里:清掉本地任务,允许重新开始。 + resetResult(); + notifyRef.current?.("info", error instanceof Error ? error.message : "已停止当前生成"); + } finally { + setCancelling(false); + } + } + + const isGenerating = submitting || job?.status === "queued" || job?.status === "running"; + const isComplete = job?.status === "succeeded"; + const isCancelled = job?.status === "cancelled"; + const isFailed = job?.status === "failed" || isCancelled || serviceUnavailable; + const displayUrls = [...savedImages.map((image) => image.url), ...imagePreviews]; + const imageCount = savedImages.length + images.length; + const canRetry = Boolean(name.trim() && imageCount && selectedVideoModel); + const activePhase = submitting ? 0 : Math.max(0, Math.min(3, job?.phase_index ?? 0)); + const result = job?.result; + const videoClips = result?.video_segments?.length + ? result.video_segments + : result?.video_url + ? [{ id: "final", sort_order: 0, duration_seconds: result.duration_seconds, video_url: result.video_url, poster_url: result.poster_url }] + : []; + const sceneCount = Math.max(1, Math.round(totalDuration / 15)); + const videoEstimate = estimateCost( + selectedVideoModel, + { ratio: aspectRatio, resolution, duration: totalDuration, refs: [] }, + billingRates, + ); + // 商品理解、基础资产和每镜故事板在脚本生成前无法精确报价;按每场约60积分给出透明预估,最终按成功任务结算。 + const estimatedPoints = videoEstimate.points > 0 ? videoEstimate.points + sceneCount * 60 : sceneCount * 240; + const shellClass = [ + "quick-create-shell", + isGenerating ? "is-generating" : "", + isComplete ? "is-complete" : "", + isFailed ? "is-failed" : "", + ].filter(Boolean).join(" "); + return (
- -

极速成片

输入商品名称并上传图片,系统将自动完成从商品理解到视频生成的全部流程

+ +

极速成片

AI 自动编排· 无需逐步配置
-
+

告诉我们这是什么商品

-

系统会识别商品信息,自动选择带货结构、表现形式、模特和场景,并完成15秒竖屏视频。

商品图片必填 · 最多9张 -
+ +
+ + + +
-
识别商品与卖点推荐脚本方向匹配模特与场景生成故事板与视频
-
-
仅在视频生成成功后扣费预计 240 积分
- + {isGenerating ? ( + + ) : ( + + )}
-

系统会替你完成所有选择

-

上传商品后,AI 将根据品类、图片信息和适用场景自动编排完整视频,不需要理解复杂的制作参数。

-
自动推荐结构自动选择表现形式自动匹配资产自动质量检查
+

核心参数可选,其余自动完成

+
+ +
+
+

正在为商品生成视频

{job?.message || "正在生成视频并进行质量检查…"}

+
+ {PROGRESS_STEPS.map((step, index) => { + const Icon = step.icon; + const done = index < activePhase; + const active = isGenerating && index === activePhase; + return
{step.label}
; + })} +
+
+ +
+
+ +
+
+ {videoClips.map((clip, index) => ( + + ))} +
+

{videoClips.length || 1} 个视频已生成

{videoClips.length || 1}场 · 每场{videoClips[0]?.duration_seconds || 15}秒 · {result?.aspect_ratio || "9:16"} · {(result?.resolution || "720p").toUpperCase()} · {result?.video_model || "智能模型"}

质量检查通过
+
1 ? "" : " is-single"}`}> + + {videoClips.length > 1 ? ( + + ) : null} + {result?.video_url ? 下载视频 : } +
+
+ +
+ +

{serviceUnavailable ? "极速成片暂不可用" : isCancelled ? "已取消本次生成" : "本次生成未完成"}

+

{serviceUnavailable ? (unavailableMessage || "后台生成服务未就绪,请确认 Celery worker 已启动后再试。") : job?.error_message || "生成过程中遇到问题,可以重试或重新开始。"}

+
+ {canRetry && !serviceUnavailable ? ( + + ) : null} + + {job?.project_id ? ( + + ) : null} +
+ +
+
+

过往极速成片项目

+ {history.length}个项目 +
+ {history.length ? ( +
+ {history.map((item) => { + const poster = item.result?.poster_url || ""; + const videoUrl = item.result?.video_url || item.result?.video_segments?.[0]?.video_url || ""; + const duration = item.result?.duration_seconds || item.settings?.total_duration || 15; + const scenes = item.result?.video_segments?.length || Math.max(1, Math.round(duration / 15)); + return ( +
+ +
+ 已完成 +

{historyTitle(item)}

+

极速成片 · {scenes}场 · {item.result?.aspect_ratio || item.settings?.aspect_ratio || "9:16"} · {(item.result?.resolution || item.settings?.resolution || "720p").toUpperCase()} · {item.result?.video_model || item.settings?.video_model_label || "智能模型"}

+
+ +
+ ); + })} +
+ ) : ( +

还没有完成的极速成片,生成成功后会出现在这里。

+ )} +
+ + {playing ? ( +
setPlaying(null)} role="presentation"> +
event.stopPropagation()} role="dialog" aria-modal="true" aria-label={playing.title}> +
+ {playing.title} + +
+
+
+ ) : null}
); } diff --git a/core/frontend/src/routes/route-config.ts b/core/frontend/src/routes/route-config.ts index a5c825d..cb92687 100644 --- a/core/frontend/src/routes/route-config.ts +++ b/core/frontend/src/routes/route-config.ts @@ -165,7 +165,9 @@ export function resolveRoute(): ResolvedRoute { if (path === "/messages") return { page: "messages", authMode: "login", hash }; if (path === "/asset-factory") return { page: "assetFactory", authMode: "login", hash }; if (path === "/free-create") return { page: "freeCreate", authMode: "login", hash }; - if (path === "/quick-create") return { page: "quickCreate", authMode: "login", hash }; + if (path === "/quick-create") { + return { page: "quickCreate", authMode: "login", productId: search.get("product_id") || undefined, hash }; + } if (path === "/video-remix") return { page: "videoRemix", authMode: "login", hash }; if (path === "/image-optimize") { return { page: "imageOptimize", authMode: "login", productId: search.get("product_id") || undefined, hash }; @@ -211,7 +213,7 @@ export function pathForPage(page: Page, options: NavigateOptions = {}) { case "freeCreate": return "/free-create"; case "quickCreate": - return "/quick-create"; + return options.productId ? `/quick-create?product_id=${encodeURIComponent(options.productId)}` : "/quick-create"; case "videoRemix": return "/video-remix"; case "imageOptimize": diff --git a/core/frontend/src/routes/video-remix.tsx b/core/frontend/src/routes/video-remix.tsx index 67cbf57..824f040 100644 --- a/core/frontend/src/routes/video-remix.tsx +++ b/core/frontend/src/routes/video-remix.tsx @@ -5,9 +5,12 @@ import { ArrowRight, BadgeCheck, Clock3, + Copy, + FileText, FileVideo, FileVideo2, PanelsTopLeft, + Play, RectangleVertical, Save, ScanLine, @@ -15,45 +18,15 @@ import { TextCursorInput, } from "lucide-react"; import { api } from "../api"; -import type { ModelConfig } from "../types"; +import { MediaLightbox } from "../components/overlays"; +import type { ModelConfig, VideoDigestHistory } from "../types"; import type { NavigateFn } from "./route-config"; export const REMIX_PROMPT_KEY = "fc-remix-prompt"; const REMIX_DRAFT_KEY = "vr-digest-draft"; const VIDEO_DIGEST_POINTS = 30; -type RemixDraft = { - prompt: string; - duration: number; - shots: number; - ratio: string; - fileName: string; - fileSize: number; - fileKind: string; - width: number; - height: number; -}; - -function loadDraft(): RemixDraft | null { - try { - const raw = localStorage.getItem(REMIX_DRAFT_KEY); - if (!raw) return null; - const draft = JSON.parse(raw) as RemixDraft; - if (typeof draft.prompt !== "string" || !draft.prompt.trim()) return null; - return draft; - } catch { - return null; - } -} - -function saveDraft(draft: RemixDraft) { - try { - localStorage.setItem(REMIX_DRAFT_KEY, JSON.stringify(draft)); - localStorage.setItem(REMIX_PROMPT_KEY, draft.prompt); - } catch { - /* 隐私模式写满时静默,内存态仍可用 */ - } -} +type ProgressStage = "upload" | "analyze" | "prompt"; const VIDEO_DIGEST_MAX_BYTES = 200 * 1024 * 1024; @@ -116,6 +89,20 @@ function fileMetaCopy(kind: string, size: number, width: number, height: number) return kind; } +function progressClass(step: ProgressStage, stage: ProgressStage) { + const order: ProgressStage[] = ["upload", "analyze", "prompt"]; + const active = order.indexOf(stage); + const index = order.indexOf(step); + if (index < active) return "remix-progress-step done"; + if (index === active) return "remix-progress-step active"; + return "remix-progress-step"; +} + +function historySummary(item: VideoDigestHistory) { + const bits = [item.ratio || "—", item.shots ? `${item.shots} 个镜头` : "—", item.file_name || "参考视频.mp4"]; + return bits.join(" · "); +} + export function VideoRemixPage({ textModels = [], onNotify, onBack, navigate }: { textModels?: ModelConfig[]; onNotify: (type: "success" | "error" | "info", text: string) => void; @@ -124,18 +111,22 @@ export function VideoRemixPage({ textModels = [], onNotify, onBack, navigate }: }) { const [file, setFile] = useState(null); const [busy, setBusy] = useState(false); - const [draft] = useState(loadDraft); - const [prompt, setPrompt] = useState(draft?.prompt || ""); - const [duration, setDuration] = useState(draft?.duration || 0); - const [shots, setShots] = useState(draft?.shots || 0); - const [ratio, setRatio] = useState(draft?.ratio || ""); - const [fileName, setFileName] = useState(draft?.fileName || ""); - const [fileSize, setFileSize] = useState(draft?.fileSize || 0); - const [kind, setKind] = useState(draft?.fileKind || "MP4"); - const [width, setWidth] = useState(draft?.width || 0); - const [height, setHeight] = useState(draft?.height || 0); - const [hasResult, setHasResult] = useState(Boolean(draft?.prompt.trim())); + const [prompt, setPrompt] = useState(""); + const [duration, setDuration] = useState(0); + const [shots, setShots] = useState(0); + const [ratio, setRatio] = useState(""); + const [fileName, setFileName] = useState(""); + const [fileSize, setFileSize] = useState(0); + const [kind, setKind] = useState("MP4"); + const [width, setWidth] = useState(0); + const [height, setHeight] = useState(0); + const [taskId, setTaskId] = useState(""); + const [hasResult, setHasResult] = useState(false); + const [history, setHistory] = useState([]); + const [openHistoryId, setOpenHistoryId] = useState(""); + const [playing, setPlaying] = useState(null); const promptRef = useRef(null); + const previewUrl = useMemo(() => (file ? URL.createObjectURL(file) : ""), [file]); const digestModels = useMemo( () => textModels.filter((model) => model.status === "active" && isGemini31(model)), @@ -150,20 +141,27 @@ export function VideoRemixPage({ textModels = [], onNotify, onBack, navigate }: ? VIDEO_DIGEST_POINTS : Math.max(1, Math.round(Number((VIDEO_DIGEST_POINTS * priceMultiplier).toFixed(6)))); + const loadHistory = async () => { + try { + const data = await api.listVideoDigests(); + setHistory(data.results || []); + } catch { + /* 历史失败不挡当前拆解 */ + } + }; + useEffect(() => { - if (!prompt.trim()) return; - saveDraft({ - prompt, - duration, - shots, - ratio, - fileName: file?.name || fileName, - fileSize: file?.size || fileSize, - fileKind: kind, - width, - height, - }); - }, [prompt, duration, shots, ratio, file, fileName, fileSize, kind, width, height]); + try { + localStorage.removeItem(REMIX_DRAFT_KEY); + } catch { + /* 无痕模式忽略 */ + } + void loadHistory(); + }, []); + + useEffect(() => () => { + if (previewUrl) URL.revokeObjectURL(previewUrl); + }, [previewUrl]); useEffect(() => { const el = promptRef.current; @@ -192,6 +190,7 @@ export function VideoRemixPage({ textModels = [], onNotify, onBack, navigate }: setRatio(ratioLabel(meta.width, meta.height)); if (meta.duration) setDuration(Math.round(meta.duration)); setHasResult(false); + setTaskId(""); }; const analyze = async () => { @@ -205,11 +204,16 @@ export function VideoRemixPage({ textModels = [], onNotify, onBack, navigate }: const text = digest.text.trim(); setPrompt(text); setDuration(digest.duration || duration); - setShots(shotCount(text, digest.frames)); - setFileName(file.name); + setShots(digest.shots || shotCount(text, digest.frames)); + setFileName(digest.file_name || file.name); setFileSize(file.size); + if (digest.ratio) setRatio(digest.ratio); + if (digest.width) setWidth(digest.width); + if (digest.height) setHeight(digest.height); + setTaskId(digest.task_id || ""); setHasResult(true); onNotify("success", "视频拆解完成,已生成提示词"); + void loadHistory(); } catch (error) { onNotify("error", error instanceof Error && error.message ? error.message : "视频拆解失败,请重试"); } finally { @@ -221,6 +225,15 @@ export function VideoRemixPage({ textModels = [], onNotify, onBack, navigate }: const text = prompt.trim(); if (!text) return; sessionStorage.setItem(REMIX_PROMPT_KEY, text); + if (taskId) { + try { + const saved = await api.saveVideoDigest(taskId, text); + setHistory((items) => items.map((item) => (item.id === saved.id ? saved : item))); + } catch { + onNotify("error", "提示词保存失败,请重试"); + return; + } + } try { await navigator.clipboard.writeText(text); onNotify("success", "提示词已保存,可继续生成或直接粘贴"); @@ -236,10 +249,21 @@ export function VideoRemixPage({ textModels = [], onNotify, onBack, navigate }: navigate("freeCreate"); }; + const copyHistoryPrompt = async (text: string) => { + try { + await navigator.clipboard.writeText(text); + onNotify("success", "提示词已复制"); + } catch { + onNotify("error", "复制失败,请手动选择文本"); + } + }; + const analyzeLabel = busy ? "正在拆解…" : `生成这个需要 ${estimatedPoints} 积分`; + const stage: ProgressStage = busy ? "analyze" : hasResult ? "prompt" : file ? "analyze" : "upload"; + return (
@@ -256,6 +280,21 @@ export function VideoRemixPage({ textModels = [], onNotify, onBack, navigate }:
+
+
+ 1 + 上传视频 +
+
+ 2 + 智能拆解 +
+
+ 3 + 生成提示词 +
+
+

上传参考视频

@@ -264,28 +303,40 @@ export function VideoRemixPage({ textModels = [], onNotify, onBack, navigate }: 参考视频 MP4 / MOV · 最长 60 秒
- + {file && previewUrl ? ( +
+
+ ) : ( + + )}
+ +
+
+

提取过的项目

+ {history.length} 个项目 +
+ {history.length === 0 ? ( +
还没有提取过的项目
+ ) : ( +
+ {history.map((item) => { + const open = openHistoryId === item.id; + const promptId = `remix-history-prompt-${item.id}`; + return ( +
+
{ + if (item.video_url) { + setPlaying(item); + return; + } + onNotify("info", "这条没有保存原片,重新上传拆一次就能播放"); + }} + onKeyDown={(event) => { + if (event.key !== "Enter" && event.key !== " ") return; + event.preventDefault(); + event.currentTarget.click(); + }} + role="button" + tabIndex={0} + aria-label={`播放 ${item.title}`} + > + {item.cover_url ? ( + {`${item.title}封面`} + ) : null} + + {item.duration_label || "00:00"} +
+
+
+ {item.status || "已完成"} +

{item.title}

+

{historySummary(item)}

+
+
+ + +
+
+