From c25060c6c68e19a07f58aefc11379343f021b49b Mon Sep 17 00:00:00 2001 From: "Azmat@qq.com" Date: Wed, 26 Aug 2026 18:44:03 +0800 Subject: [PATCH] =?UTF-8?q?=E4=BC=98=E5=8C=96=E4=BD=8F=E6=B5=81=E7=A8=8B?= =?UTF-8?q?=E5=92=8C=E6=B7=BB=E5=8A=A0=E5=A4=8D=E5=88=BB=E8=A7=86=E9=A2=91?= =?UTF-8?q?=E9=A1=B5=E9=9D=A2?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- core/backend/apps/ai/script_agent.py | 158 ++- core/backend/apps/ai/services.py | 21 +- .../apps/ai/test_script_duration_combo.py | 91 +- .../apps/ai/test_video_caption_policy.py | 16 + .../apps/ai/test_video_segment_routing.py | 4 +- core/backend/apps/ai/tryon_prompt.py | 2 +- core/backend/apps/products/models.py | 5 + core/backend/apps/projects/serializers.py | 11 +- .../apps/projects/services/quick_create.py | 30 +- .../apps/projects/test_quick_create.py | 77 ++ core/backend/apps/projects/views.py | 17 +- core/frontend/src/App.tsx | 12 + core/frontend/src/api.ts | 5 +- core/frontend/src/components/IconKitSvg.tsx | 1 + core/frontend/src/components/app-shell.tsx | 4 +- .../src/components/product-create-drawer.tsx | 65 +- core/frontend/src/main.tsx | 1 + core/frontend/src/product-business.ts | 14 +- core/frontend/src/product-create-page.css | 23 + core/frontend/src/quick-create-page.css | 21 +- core/frontend/src/routes/dashboard.tsx | 2 +- core/frontend/src/routes/index.ts | 1 + core/frontend/src/routes/pipeline.tsx | 32 +- core/frontend/src/routes/products.tsx | 37 +- core/frontend/src/routes/projects.tsx | 2 +- core/frontend/src/routes/quick-create.tsx | 99 +- core/frontend/src/routes/route-config.ts | 7 +- core/frontend/src/routes/video-replace.tsx | 768 +++++++++++ core/frontend/src/script-setup.ts | 78 +- core/frontend/src/video-replace-page.css | 1125 +++++++++++++++++ 30 files changed, 2574 insertions(+), 155 deletions(-) create mode 100644 core/backend/apps/ai/test_video_caption_policy.py create mode 100644 core/frontend/src/routes/video-replace.tsx create mode 100644 core/frontend/src/video-replace-page.css diff --git a/core/backend/apps/ai/script_agent.py b/core/backend/apps/ai/script_agent.py index f65a448..29a1d44 100644 --- a/core/backend/apps/ai/script_agent.py +++ b/core/backend/apps/ai/script_agent.py @@ -68,6 +68,51 @@ STRUCTURE_MIN_DURATION: dict[str, int] = { "pain": 15, "contrast": 15, "review": 30, "scene": 30, "promo": 15, "knowledge": 30, } +# 用户在设定卡里选的是「怎么说服」,不是一个仅供展示的标签。这里把每种结构收成 +# 可直接塞进任务上下文的验收合同;模型不得拿痛点结构去代替知识分享、拿种草去代替测评。 +STRUCTURE_CONTRACTS: dict[str, str] = { + "pain": ( + "【痛点解决·结构合同】必须按顺序完成:具体问题出现 → 这个问题造成的不便 → 商品作为解法介入" + "→ 对应卖点与真实使用过程 → 可见的改善 → 一个行动引导。开头先给可拍到的具体问题," + "商品不能在第一句话直接硬推;一个痛点只对应一个卖点。禁止把普通不便夸成严重后果或制造焦虑。\n" + ), + "contrast": ( + "【前后对比·结构合同】必须建立同一人物/场景/角度/光线下可比较的“使用前”和“使用后”," + "中间必须有商品使用过程作为转折,再总结差异并 CTA。旁白只交代条件、过程和差异," + "不能写成长篇原理讲解;不得跳过过程、换条件伪造差异,或同时对比多个维度。\n" + ), + "review": ( + "【测评验证·结构合同】必须按顺序完成:提出要验证什么 → 说清测评标准/条件 → 完整展示测试动作" + "→ 呈现画面可观察的结果 → 给出带适用边界的结论和 CTA。开场必须是疑问或待验证目标," + "不是直接夸商品;每个卖点都要改写成可演示的验证项目。禁止虚构检测数据、权威结论、用户评价或编造缺点。\n" + ), + "scene": ( + "【场景种草·结构合同】必须按顺序完成:先交代人物、具体时间和地点 → 场景中自然出现需求" + "→ 商品以正在被使用的方式入场 → 写出该场景里的真实体验 → 轻度推荐/软 CTA。第一镜先建立场景," + "不要一上来抛痛点提问或商品参数;商品不能像硬广道具摆拍,卖点要融入动作与感受。\n" + ), + "promo": ( + "【促销抢购·结构合同】必须按顺序完成:真实福利/价格钩子(仅在资料明确时)→ 商品基础价值" + "→ 优惠适用的规格、条件与领取方式 → 真实的时间/库存信息(仅在资料明确时)→ 明确购买入口和操作 CTA。" + "若资料没有价格、优惠、库存或时间,就不得捏造或制造抢购氛围,改为商品价值与查看详情的引导。\n" + ), + "knowledge": ( + "【知识分享·结构合同】必须按顺序完成:提出一个选购/使用问题或常见误区 → 给出清楚的判断方法" + "→ 用实物细节、步骤或正确/错误示范讲清方法 → 商品在方法建立后作为符合该标准的实例出现" + "→ 总结选择建议和自然 CTA。只讲 1–3 个知识点,商品不能在开头就变成卖点罗列。" + "禁止虚构数据、标准、专业身份或功效承诺,也不能只靠人物口头科普而没有画面演示。\n" + ), +} + +STRUCTURE_STAGES: dict[str, tuple[str, ...]] = { + "pain": ("具体问题与不便", "商品介入和真实用法", "可见改善与行动引导"), + "contrast": ("使用前的基准状态", "商品使用过程", "使用后的同条件状态", "差异总结与行动引导"), + "review": ("测评目标与标准", "完整测试过程", "可观察的结果", "适用结论与行动引导"), + "scene": ("具体时间、地点与人物状态", "场景中的自然需求", "商品被使用着入场", "真实体验与软 CTA"), + "promo": ("真实福利或商品价值钩子", "商品与适用场景", "明确的优惠条件和购买方法", "入口与操作 CTA"), + "knowledge": ("问题或常见误区", "判断方法或核心结论", "实物步骤或正反示范", "商品承接、选择建议与 CTA"), +} + # 设定卡人物 key → 中文(与前端 WIZ_PERSONA_LABEL / 模板 coerce_persona 对齐)。 PERSONA_LABELS: dict[str, str] = { "urban": "都市白领女性", @@ -177,6 +222,68 @@ def coerce_combo(fmt: str | None, structure: str | None) -> tuple[str, str]: return fmt, structure +# 与前端 script-setup.ts CATEGORY_COMBOS 对齐。改这里时两边一起改。 +_CATEGORY_COMBOS: dict[str, dict[str, str]] = { + "美妆个护": {"format": "oral", "structure": "contrast", "persona": "bestie"}, + "食品饮料": {"format": "oral", "structure": "scene", "persona": "bestie"}, + "服饰鞋包": {"format": "oral", "structure": "scene", "persona": "urban"}, + "家居日用": {"format": "oral", "structure": "pain", "persona": "mom"}, + "数码家电": {"format": "oral", "structure": "review", "persona": "reviewer"}, + "母婴玩具": {"format": "oral", "structure": "pain", "persona": "mom"}, + "运动健康": {"format": "oral", "structure": "scene", "persona": "urban"}, + "珠宝配饰": {"format": "oral", "structure": "scene", "persona": "ceo"}, + "汽车用品": {"format": "oral", "structure": "review", "persona": "reviewer"}, + "宠物用品": {"format": "oral", "structure": "pain", "persona": "mom"}, + "图书文教": {"format": "oral", "structure": "knowledge", "persona": "urban"}, + "五金农资": {"format": "oral", "structure": "review", "persona": "reviewer"}, + "其他商品": {"format": "oral", "structure": "pain", "persona": "urban"}, +} +_CATEGORY_KEYWORD_RULES: tuple[tuple[tuple[str, ...], dict[str, str]], ...] = ( + (("美妆", "护肤", "彩妆", "面膜", "精华", "洗护", "个护"), _CATEGORY_COMBOS["美妆个护"]), + (("保健", "营养", "膳食", "益生菌", "维生素"), {"format": "oral", "structure": "review", "persona": "reviewer"}), + (("数码", "3c", "电子", "电器", "手机", "耳机", "相机", "工具", "家电"), _CATEGORY_COMBOS["数码家电"]), + (("食品", "零食", "饮料", "咖啡", "茶", "生鲜", "酒"), _CATEGORY_COMBOS["食品饮料"]), + (("服饰", "女装", "男装", "服装", "鞋", "包", "配饰", "内衣"), _CATEGORY_COMBOS["服饰鞋包"]), + (("家居", "家纺", "收纳", "厨具", "清洁", "日用"), _CATEGORY_COMBOS["家居日用"]), + (("母婴", "宝宝", "儿童", "玩具"), _CATEGORY_COMBOS["母婴玩具"]), + (("宠物",), _CATEGORY_COMBOS["宠物用品"]), + (("户外", "露营", "运动", "健身"), _CATEGORY_COMBOS["运动健康"]), +) +_FALLBACK_COMBO: dict[str, str] = {"format": "oral", "structure": "pain", "persona": "urban"} + + +def recommend_script_setup(category: str = "", title: str = "") -> dict: + """按商品品类推荐表现形式 × 视频结构 × 人物 × 时长。用户随时可改。""" + category = (category or "").strip() + title = (title or "").strip() + combo = _FALLBACK_COMBO + reason = "商品信息不足,先给一组最通用的" + if category in _CATEGORY_COMBOS: + combo = _CATEGORY_COMBOS[category] + reason = f"按「{category}」品类推荐" + else: + haystack = f"{category} {title}".lower() + for keywords, item in _CATEGORY_KEYWORD_RULES: + hit = next((word for word in keywords if word.lower() in haystack), None) + if hit: + combo = item + reason = f"按「{hit}」品类推荐" + break + # 自动推荐只给口播;Vlog / 短剧留给用户自己选。 + fmt, structure = coerce_combo("oral", combo["structure"]) + persona = coerce_persona(combo.get("persona") or "urban") + if persona not in PERSONA_LABELS: + persona = "urban" + duration = max(FORMAT_DEFAULT_DURATION[fmt], STRUCTURE_MIN_DURATION[structure]) + return { + "format": fmt, + "structure": structure, + "persona": persona, + "duration": duration, + "reason": reason, + } + + def narration_limit(duration: int) -> int: """这一镜旁白的字数上限:秒数 × 5.2,且不超过硬上限 78。""" return max(1, min(NARRATION_CHARS_HARD_CAP, int(duration * NARRATION_CHARS_PER_SECOND))) @@ -367,8 +474,12 @@ _CREATIVE_DIRECTION = """ ## 出片感与转化感(硬性创作方向) +**先服从用户选定的视频结构。** 用户选的是痛点解决、前后对比、测评验证、场景种草、促销抢购或知识分享中的哪一种, +就只走该结构合同的顺序与说服方式;不能因为“更好写”而擅自换成痛点开场、测评口吻或场景氛围。 +下列通用写法仅用于增强已选结构,和结构合同冲突时一律以结构合同为准。 + 你的脚本不能像商品详情页、说明书或主播念稿;它必须让用户在第一秒看到一个 -**正在发生的具体瞬间**。先选一个最能代表目标人群的「时间 + 地点 + 小麻烦/小欲望」, +**正在发生的具体瞬间**。先选一个最能代表典型使用者的「时间 + 地点 + 小麻烦/小欲望」, 全片只围绕这一个情境推进。不要在一条短视频里罗列所有卖点。 ### 每一镜都必须有推进,禁止平铺直叙 @@ -487,8 +598,7 @@ def _product_context(project, selling_point_ids: list[str] | None, persona: str f"品牌:{product.brand or '未填写'}\n" f"{type_line}" f"品类:{product.category or '未填写'}\n" - f"目标人群:{product.target_audience or '未填写'}\n" - f"人物设定:{persona_text or '未指定,按目标人群自行定一个具体身份'}\n" + f"人物设定:{persona_text or '未指定,按商品信息自行定一个具体身份'}\n" f"人物口吻:{persona_brief or '按人物设定自己定一个具体身份,不要用万能主播腔'}\n" f"商品描述:{desc}\n" f"规格:{specs_block}\n" @@ -572,6 +682,7 @@ def build_agent_messages( suggested = plan_segment_durations(total, fmt) shot_n = len(suggested) + stage_plan = structure_stage_plan(structure, shot_n) extra = (user_prompt or "").strip() if target_index is not None and base_draft: existing_n = len(base_draft.get("segments") or []) @@ -610,24 +721,10 @@ def build_agent_messages( f"【写法硬约束】有人声的镜口播/对白 {speech_floor}–{speech_cap} 字,允许个别镜纯画面;" f"visual 至少 {VISUAL_CHARS_MIN} 字。\n" ) - structure_line = "" - if structure == "scene": - structure_line = ( - "【场景种草骨架】按镜走完:场景建立(商品先不出现)→商品被使用着入场→场景因它更好/软CTA。" - "每镜写出具体时刻+地点+光/声音/物件。口播时每镜仍要说满字数,留白靠停顿不是靠少写字。\n" - ) - elif structure == "promo": - structure_line = ( - "【促销抢购合规】只可使用商品资料中明确给出的价格、优惠、库存、赠品和活动时间;" - "没有明确数据时,绝不写原价、最低价、限时、限量、领券或赠品。" - "结尾只给一个清楚的购买入口和操作动作,不制造虚假的紧迫感。\n" - ) - elif structure == "knowledge": - structure_line = ( - "【知识分享骨架】先提出一个与商品相关的选购/使用误区或判断方法,再用实物与步骤证明," - "最后把商品作为符合该判断的具体实例。只能讲商品资料可支持的事实," - "禁止编造专业身份、数据、标准或功效承诺。\n" - ) + structure_line = STRUCTURE_CONTRACTS[structure] + stage_line = "【镜序验收】" + ";".join( + f"第 {index + 1} 镜:{stage}" for index, stage in enumerate(stage_plan) + ) + "。镜数不够时只可合并相邻步骤,顺序不得颠倒,也不得插入其他结构的主线。\n" combo_line = ( f"严格按已加载的「{PRESENTATION_FORMATS[fmt]} × {VIDEO_STRUCTURES[structure]}」套路写," f"不要串成别的结构的套话。\n" @@ -646,6 +743,7 @@ def build_agent_messages( f"{writing_line}" f"{beats_line}" f"{structure_line}" + f"{stage_line}" f"{combo_line}" f"{authenticity_line}" f"【商品信息】\n{_product_context(project, selling_point_ids, persona)}" @@ -685,7 +783,7 @@ def build_agent_messages( "写 visual 时把拆解稿里的景别/机位/运镜/人物动作/表情/音效/背景音乐/字幕/备注" "折进【声音】和【画面内容】的秒级分镜,不要压成一句画面摘要。" "原稿写「无 / 不可见 / 听不清」的栏不要编造。" - "钩子、痛点、卖点必须能对上这份商品。" + "钩子、已选结构的核心步骤和商品事实必须能对上。" "每镜旁白要能撑满指定时长;画面必须按秒拆分镜,够导演在 15 秒里切 3–5 刀。\n" f"{head}" f"{extra_block}\n" @@ -695,7 +793,7 @@ def build_agent_messages( user = ( "【任务】全自动(模式①):仅凭上面的商品事实与前置条件,按指定的表现形式与视频结构套路" "自动定镜/选 tone/造 entity/填结构骨架。" - "不要另起一个空主题;钩子、痛点、卖点必须能对上这份商品,而不是品类套话。" + "不要另起一个空主题;钩子、已选结构的核心步骤和商品事实必须能对上这份商品,而不是品类套话。" "每镜旁白要能撑满 15 秒;画面必须按秒拆分镜,够导演在 15 秒里切 3–5 刀。\n" f"{head}" f"{extra_block}\n" @@ -793,6 +891,20 @@ def plan_segment_durations(total_duration: int, presentation_format: str | None return [SEGMENT_DURATION_MAX] * count +def structure_stage_plan(video_structure: str, segment_count: int) -> list[str]: + """把选中结构的完整说服路径按当前镜数连续压缩,绝不换成通用痛点模板。""" + stages = list(STRUCTURE_STAGES[video_structure]) + count = max(1, int(segment_count or 1)) + if count >= len(stages): + return stages + [stages[-1]] * (count - len(stages)) + # 前面镜先承接前置步骤,最后一镜必须保留收束/CTA;每个阶段只会向相邻镜合并。 + groups: list[list[str]] = [[] for _ in range(count)] + for index, stage in enumerate(stages): + target = min(count - 1, index * count // len(stages)) + groups[target].append(stage) + return [" → ".join(group) for group in groups] + + def plan_roles(count: int) -> list[str]: """镜数 → role 序列。通用规则:首钩子、次痛点、末 CTA,中间全是卖点。""" if count <= 1: diff --git a/core/backend/apps/ai/services.py b/core/backend/apps/ai/services.py index eda804c..a6b4899 100644 --- a/core/backend/apps/ai/services.py +++ b/core/backend/apps/ai/services.py @@ -894,6 +894,20 @@ class VideoSubmissionStateUnknown(ProviderOutcomeUnknownError): """视频提交可能已到达供应商但未拿到可靠任务 ID;禁止自动重提。""" +NO_EMBEDDED_CAPTIONS_REQUIREMENT = ( + "【成片硬性规则】画面不得出现字幕、台词字幕、花字、标题、价格贴片、UI 文字或其他后期文字叠加;" + "只保留商品包装上参考图中原有的真实物理文字与标识。旁白和环境音可以保留,但不要把声音转成画面文字。" +) + + +def enforce_no_embedded_captions(prompt: str) -> str: + """所有视频入口最终汇入这里,避免某个入口漏传“无字幕”导致模型自行加花字。""" + base = (prompt or "").strip() + if NO_EMBEDDED_CAPTIONS_REQUIREMENT in base: + return base + return f"{base}\n{NO_EMBEDDED_CAPTIONS_REQUIREMENT}".strip() + + def execute_routed_video_submit( *, task: AITask, @@ -913,6 +927,7 @@ def execute_routed_video_submit( """异步视频只路由“提交”阶段;拿到 Provider 任务 ID 后固定该实际模型轮询。""" from apps.billing.pricing import quote_video_estimate + prompt = enforce_no_embedded_captions(prompt) references = list(reference_images or []) routed_content_items = list(content_items) if content_items is not None else None pricing_refs = list(pricing_references or []) @@ -2693,7 +2708,7 @@ def build_storyboard_frame_prompt(project, segment, extra_prompt: str = "") -> s 补充=(("\n" + extra_prompt.strip()) if extra_prompt else ""), ) cleaned = "\n".join(line for line in rendered.split("\n") if line.strip()) - return _apply_storyboard_output_ratio(cleaned, project) + return enforce_no_embedded_captions(_apply_storyboard_output_ratio(cleaned, project)) def build_video_segment_prompt(project, video_segment, scene, refs, user_prompt: str = "") -> str: @@ -2729,7 +2744,7 @@ def build_video_segment_prompt(project, video_segment, scene, refs, user_prompt: 脚本=(script_text or f"第 {video_segment.sort_order + 1} 段"), 时长=video_segment.target_duration_seconds, ) - return "\n".join(line for line in rendered.split("\n") if line.strip()) + return enforce_no_embedded_captions("\n".join(line for line in rendered.split("\n") if line.strip())) def ensure_storyboard_shots(project) -> list: @@ -2904,7 +2919,7 @@ def build_storyboard_frame_prompt_refs(project, segment, refs: list[dict], extra 补充=(("\n" + extra_prompt.strip()) if extra_prompt else ""), ) cleaned = "\n".join(line for line in rendered.split("\n") if line.strip()) - return _apply_storyboard_output_ratio(cleaned, project) + return enforce_no_embedded_captions(_apply_storyboard_output_ratio(cleaned, project)) def _is_transient_error(exc: Exception) -> bool: diff --git a/core/backend/apps/ai/test_script_duration_combo.py b/core/backend/apps/ai/test_script_duration_combo.py index 7341655..e8caba0 100644 --- a/core/backend/apps/ai/test_script_duration_combo.py +++ b/core/backend/apps/ai/test_script_duration_combo.py @@ -13,6 +13,7 @@ from apps.ai.script_agent import ( DEFAULT_TOTAL_DURATION, PRESENTATION_FORMATS, SEGMENT_DURATION_MAX, + STRUCTURE_CONTRACTS, TOTAL_DURATION_MAX, TOTAL_DURATION_MIN, VIDEO_STRUCTURES, @@ -35,6 +36,8 @@ from apps.ai.script_agent import ( parse_visual_beats, plan_roles, plan_segment_durations, + recommend_script_setup, + structure_stage_plan, ) ALL_TOTALS = [15, 30, 45, 60] @@ -81,6 +84,16 @@ class SegmentPlanningTests(SimpleTestCase): self.assertEqual(plan[0], "钩子") self.assertEqual(plan[-1], "CTA") + def test_structure_stage_plan_preserves_selected_structure_order_when_compressed(self): + self.assertEqual( + structure_stage_plan("knowledge", 2), + ["问题或常见误区 → 判断方法或核心结论", "实物步骤或正反示范 → 商品承接、选择建议与 CTA"], + ) + self.assertEqual( + structure_stage_plan("contrast", 4), + ["使用前的基准状态", "商品使用过程", "使用后的同条件状态", "差异总结与行动引导"], + ) + class SegmentDurationFittingTests(SimpleTestCase): def test_uneven_durations_are_forced_to_15(self): @@ -265,7 +278,6 @@ class PromptAssemblyTests(SimpleTestCase): product.brand = "AirBrand" product.business_type = "ecommerce" product.category = "美妆" - product.target_audience = "油皮" product.description = "夜间修护" product.specs = {"price": "99", "source": "product-create-upload"} product.selling_points.all.return_value = _QS([sp]) @@ -290,6 +302,7 @@ class PromptAssemblyTests(SimpleTestCase): self.assertNotIn("一句话主题扩写", user) self.assertIn("【补充要求】节奏紧凑,适合短视频投放", user) self.assertIn("控油12小时", user) + self.assertNotIn("目标人群:", user) self.assertIn("都市白领女性", user) self.assertIn("价格:99", user) self.assertIn("每镜必须 15 秒", user) @@ -301,6 +314,38 @@ class PromptAssemblyTests(SimpleTestCase): self.assertIn("禁止使用「唯一缺点", user) self.assertIn("测试过程里的意外发现", user) + def test_selected_video_structure_injects_its_own_contract(self): + expected_titles = { + "pain": "【痛点解决·结构合同】", + "contrast": "【前后对比·结构合同】", + "review": "【测评验证·结构合同】", + "scene": "【场景种草·结构合同】", + "promo": "【促销抢购·结构合同】", + "knowledge": "【知识分享·结构合同】", + } + for structure, expected_title in expected_titles.items(): + with self.subTest(structure=structure): + messages = build_agent_messages( + project=self._project(), + mode="auto", + user_prompt="", + selling_point_ids=["1"], + base_draft=None, + aspect_ratio="9:16", + total_duration=30, + presentation_format="oral", + video_structure=structure, + persona="urban", + ) + user = messages[1]["content"] + self.assertIn(STRUCTURE_CONTRACTS[structure], user) + self.assertIn(expected_title, user) + self.assertIn("【镜序验收】", user) + self.assertIn(structure_stage_plan(structure, 2)[0], user) + for other, other_title in expected_titles.items(): + if other != structure: + self.assertNotIn(other_title, user) + def test_video_digest_prompt_maps_camera_and_sound(self): digest = ( "片名:《雨夜归家》\n视频类型:剧情短片\n" @@ -338,7 +383,7 @@ class PromptAssemblyTests(SimpleTestCase): persona="urban", ) user = messages[1]["content"] - self.assertIn("场景种草骨架", user) + self.assertIn("【场景种草·结构合同】", user) self.assertIn("口播每镜必须说满", user) self.assertIn("一共 3 镜", user) self.assertIn("秒级分镜", user) @@ -550,3 +595,45 @@ class SkillLoadingTests(SimpleTestCase): self.assertIn("每镜固定 15 秒", text) self.assertIn("商品原词", text) self.assertNotIn("单镜 4–15 秒,允许不等长", text) + + +class RecommendScriptSetupTests(SimpleTestCase): + def test_exact_category_beauty(self): + rec = recommend_script_setup("美妆个护", "精华") + self.assertEqual(rec["format"], "oral") + self.assertEqual(rec["structure"], "contrast") + self.assertEqual(rec["persona"], "bestie") + self.assertIn("美妆个护", rec["reason"]) + + def test_digital_uses_reviewer(self): + rec = recommend_script_setup("数码家电") + self.assertEqual(rec["format"], "oral") + self.assertEqual(rec["structure"], "review") + self.assertEqual(rec["persona"], "reviewer") + + def test_food_uses_oral_scene_bestie(self): + rec = recommend_script_setup("食品饮料", "轻醒咖啡") + self.assertEqual(rec["format"], "oral") + self.assertEqual(rec["structure"], "scene") + self.assertEqual(rec["persona"], "bestie") + + def test_legacy_keyword_fallback(self): + rec = recommend_script_setup("服饰内衣", "连衣裙") + self.assertEqual(rec["format"], "oral") + self.assertEqual(rec["structure"], "scene") + self.assertEqual(rec["persona"], "urban") + + def test_never_recommends_vlog_or_drama(self): + for category in ( + "美妆个护", "食品饮料", "服饰鞋包", "家居日用", "数码家电", + "母婴玩具", "运动健康", "珠宝配饰", "汽车用品", "宠物用品", + "图书文教", "五金农资", "其他商品", + ): + rec = recommend_script_setup(category) + self.assertEqual(rec["format"], "oral", category) + + def test_empty_falls_back_to_generic(self): + rec = recommend_script_setup("", "") + self.assertEqual(rec["format"], "oral") + self.assertEqual(rec["structure"], "pain") + self.assertEqual(rec["persona"], "urban") diff --git a/core/backend/apps/ai/test_video_caption_policy.py b/core/backend/apps/ai/test_video_caption_policy.py new file mode 100644 index 0000000..791363c --- /dev/null +++ b/core/backend/apps/ai/test_video_caption_policy.py @@ -0,0 +1,16 @@ +from django.test import SimpleTestCase + +from apps.ai.services import NO_EMBEDDED_CAPTIONS_REQUIREMENT, enforce_no_embedded_captions + + +class VideoCaptionPolicyTests(SimpleTestCase): + def test_always_appends_no_embedded_captions_requirement(self): + prompt = enforce_no_embedded_captions("一位用户在厨房展示商品") + + self.assertIn(NO_EMBEDDED_CAPTIONS_REQUIREMENT, prompt) + self.assertIn("一位用户在厨房展示商品", prompt) + + def test_requirement_is_not_duplicated(self): + prompt = enforce_no_embedded_captions(NO_EMBEDDED_CAPTIONS_REQUIREMENT) + + self.assertEqual(prompt.count(NO_EMBEDDED_CAPTIONS_REQUIREMENT), 1) diff --git a/core/backend/apps/ai/test_video_segment_routing.py b/core/backend/apps/ai/test_video_segment_routing.py index d009f9e..6bcde6a 100644 --- a/core/backend/apps/ai/test_video_segment_routing.py +++ b/core/backend/apps/ai/test_video_segment_routing.py @@ -10,7 +10,7 @@ from django.utils import timezone from apps.accounts.models import Team, TeamMember, User from apps.ai.models import AITask, ModelConfig, ModelProvider -from apps.ai.services import poll_video_segment, submit_video_segment +from apps.ai.services import NO_EMBEDDED_CAPTIONS_REQUIREMENT, poll_video_segment, submit_video_segment from apps.assets.models import Asset from apps.billing.models import CreditAccount, CreditLedger from apps.products.models import Product @@ -140,6 +140,8 @@ class VideoSegmentRoutingTests(TestCase): self.assertEqual(self.ledger_count(task, CreditLedger.Type.RESERVE), 1) self.assertEqual(self.ledger_count(task, CreditLedger.Type.CHARGE), 0) self.assertEqual(self.ledger_count(task, CreditLedger.Type.RELEASE), 0) + submitted_prompt = provider.create_video_task.call_args.kwargs["prompt"] + self.assertIn(NO_EMBEDDED_CAPTIONS_REQUIREMENT, submitted_prompt) def test_second_submit_does_not_create_another_task(self): primary = self.model(self.provider("video-primary-once", 20), "video-primary-once", outbound=True, default=True) diff --git a/core/backend/apps/ai/tryon_prompt.py b/core/backend/apps/ai/tryon_prompt.py index 1229dae..0c8e857 100644 --- a/core/backend/apps/ai/tryon_prompt.py +++ b/core/backend/apps/ai/tryon_prompt.py @@ -488,7 +488,7 @@ _KEYWORDS: tuple[tuple[ProductKind, tuple[str, ...]], ...] = ( _GENERIC_WEARABLE_HINTS = ( "围巾", "披肩", "领带", "领结", "袜", "腰带", "皮带", "手套", "护膝", "护腕", "服饰", "服装", "穿戴", "衣帽", "鞋服", ) -_BROAD_WEARABLE_CATEGORIES = ("服饰内衣", "服饰", "服装", "女装", "男装", "配饰") +_BROAD_WEARABLE_CATEGORIES = ("服饰内衣", "服饰鞋包", "服饰", "服装", "女装", "男装", "配饰") _USER_RELATION_RULES: tuple[tuple[ProductKind, tuple[str, ...]], ...] = ( (ProductKind.HANDHELD, ("手持", "拿着", "拿在手", "握住")), (ProductKind.WEARABLE_GENERIC, ("穿上", "穿着", "换上", "佩戴", "戴上", "戴着")), diff --git a/core/backend/apps/products/models.py b/core/backend/apps/products/models.py index 54a9cf7..7d1610e 100644 --- a/core/backend/apps/products/models.py +++ b/core/backend/apps/products/models.py @@ -12,6 +12,11 @@ class Product(TeamOwnedModel): ECOMMERCE = "ecommerce", "电商" LOCAL_LIFE = "local_life", "本地生活" + ECOMMERCE_CATEGORIES = ( + "美妆个护", "食品饮料", "服饰鞋包", "家居日用", "数码家电", "母婴玩具", "运动健康", + "珠宝配饰", "汽车用品", "宠物用品", "图书文教", "五金农资", "其他商品", + ) + title = models.CharField(max_length=255) brand = models.CharField(max_length=128, blank=True) # 类目(电商/本地生活)≠品类(美妆/食品)。类目驱动节点屏蔽与后续 skill 挂载。 diff --git a/core/backend/apps/projects/serializers.py b/core/backend/apps/projects/serializers.py index 253f85d..26e8dd3 100644 --- a/core/backend/apps/projects/serializers.py +++ b/core/backend/apps/projects/serializers.py @@ -423,11 +423,12 @@ class QuickCreateJobSerializer(serializers.ModelSerializer): if version is not None and is_playable_video(version.asset): video_url = _asset_preview_url(version.asset) break - 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 "" + product = getattr(project, "product", None) + cover = getattr(product, "cover_asset", None) if product is not None else None + poster_url = _asset_preview_url(cover) if cover else "" + if not poster_url and product is not None: + first_image = next(iter(product.images.all()), None) + poster_url = _asset_preview_url(first_image.asset) if first_image 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( diff --git a/core/backend/apps/projects/services/quick_create.py b/core/backend/apps/projects/services/quick_create.py index 3b974a8..0cf868c 100644 --- a/core/backend/apps/projects/services/quick_create.py +++ b/core/backend/apps/projects/services/quick_create.py @@ -15,7 +15,13 @@ 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.script_agent import ( + PRESENTATION_FORMATS, + VIDEO_STRUCTURES, + persona_label, + recommend_script_setup, + stream_script_agent, +) from apps.ai.services import ( collect_video_review_blockers, create_export_job, @@ -364,6 +370,17 @@ def _consume_script_agent(job: QuickCreateJob) -> None: if model_config is None: raise ValueError(f"{QUICK_SCRIPT_MODEL_NAME} is not configured") + wizard = dict((project.metadata or {}).get("wizard") or {}) + product = project.product + rec = recommend_script_setup( + getattr(product, "category", "") or "", + getattr(product, "title", "") or "", + ) + fmt = wizard.get("presentation_format") or rec["format"] + structure = wizard.get("video_structure") or rec["structure"] + persona = wizard.get("persona") or rec["persona"] + format_label = PRESENTATION_FORMATS.get(fmt, fmt) + structure_label = VIDEO_STRUCTURES.get(structure, structure) error_detail = "" stream = stream_script_agent( project=project, @@ -371,15 +388,16 @@ def _consume_script_agent(job: QuickCreateJob) -> None: model_config=model_config, mode="auto", user_prompt=( - "请根据商品名称与商品参考图信息,自动推荐最适合的" - f"{settings['total_duration']}秒、{settings['aspect_ratio']}画幅带货方案。" + "请根据商品名称、品类与商品参考图信息,按推荐的" + f"{format_label} × {structure_label} × {persona_label(persona)}," + f"生成{settings['total_duration']}秒、{settings['aspect_ratio']}画幅带货方案。" ), aspect_ratio=settings["aspect_ratio"], total_duration=settings["total_duration"], - presentation_format="oral", - video_structure="pain", + presentation_format=fmt, + video_structure=structure, entry_source="ai", - persona="reviewer", + persona=persona, ) # 豆包思考模型会连续吐几百帧 reasoning。原先每帧 refresh_from_db,远程 MySQL # 一抖动就会把生成器关掉,模型调用被误记成 stream aborted (client disconnected)。 diff --git a/core/backend/apps/projects/test_quick_create.py b/core/backend/apps/projects/test_quick_create.py index 357d974..8dc10d0 100644 --- a/core/backend/apps/projects/test_quick_create.py +++ b/core/backend/apps/projects/test_quick_create.py @@ -76,6 +76,7 @@ class QuickCreateApiTests(TestCase): "aspect_ratio": "9:16", "resolution": "720p", "total_duration": "30", + "category": "食品饮料", }, format="multipart", ) @@ -83,10 +84,14 @@ class QuickCreateApiTests(TestCase): self.assertEqual(response.status_code, 202) product = Product.objects.get(title="轻醒咖啡") self.assertEqual(product.images.count(), 2) + self.assertEqual(product.category, "食品饮料") 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"]["presentation_format"], "oral") + self.assertEqual(project.metadata["wizard"]["video_structure"], "scene") + self.assertEqual(project.metadata["wizard"]["persona"], "bestie") 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) @@ -106,6 +111,30 @@ class QuickCreateApiTests(TestCase): {str(image.asset_id) for image in product.images.order_by("sort_order")}, ) + @patch("apps.projects.services.quick_create.get_quick_script_model", return_value=object()) + @patch("apps.projects.views.get_default_model") + @patch("apps.projects.tasks.advance_quick_create_task.apply_async") + @patch("apps.projects.views.require_worker_task") + @patch("apps.projects.views._store_uploaded_asset") + def test_submit_requires_product_category(self, store_asset, require_worker_task, enqueue, get_model, get_quick_model): + store_asset.side_effect = self._uploaded_asset + get_model.side_effect = [ + object(), + SimpleNamespace(id=uuid.uuid4(), name="seedance", display_name="Seedance", metadata={}), + ] + response = self.client.post( + "/api/projects/quick-create/", + { + "name": "无品类咖啡", + "images": [SimpleUploadedFile("front.png", b"png", content_type="image/png")], + }, + format="multipart", + ) + self.assertEqual(response.status_code, 400) + self.assertIn("品类", str(response.data["detail"])) + self.assertFalse(Product.objects.filter(title="无品类咖啡").exists()) + enqueue.assert_not_called() + @patch("apps.projects.services.quick_create.get_quick_script_model", return_value=object()) @patch("apps.projects.views.get_default_model", return_value=object()) @patch("apps.projects.tasks.advance_quick_create_task.apply_async") @@ -129,6 +158,7 @@ class QuickCreateApiTests(TestCase): { "name": "复用商品图", "source_product_id": str(source.id), + "category": "美妆个护", "aspect_ratio": "9:16", "resolution": "720p", "total_duration": "15", @@ -140,6 +170,7 @@ class QuickCreateApiTests(TestCase): store_asset.assert_not_called() product = Product.objects.get(title="复用商品图") self.assertEqual(product.images.count(), 1) + self.assertEqual(product.category, "美妆个护") self.assertEqual(product.images.first().asset_id, source_asset.id) self.assertEqual(response.data["product_images"][0]["asset_id"], str(source_asset.id)) @@ -651,6 +682,52 @@ class QuickCreateCoordinatorTests(TestCase): self.assertEqual(self.job.status, QuickCreateJob.Status.RUNNING) enqueue.assert_called_once() + @patch("apps.projects.services.quick_create.get_quick_script_model") + @patch("apps.projects.services.quick_create.stream_script_agent") + def test_consume_script_uses_category_recommendation(self, stream_fn, get_model): + get_model.return_value = self.model + self.product.category = "食品饮料" + self.product.title = "轻醒咖啡" + self.product.save(update_fields=["category", "title", "updated_at"]) + self.project.metadata = {"wizard": {"total_duration": 30, "aspect_ratio": "9:16"}} + self.project.save(update_fields=["metadata", "updated_at"]) + stream_fn.return_value = iter([]) + with self.assertRaises(ValueError): + _consume_script_agent(self.job) + kwargs = stream_fn.call_args.kwargs + self.assertEqual(kwargs["presentation_format"], "oral") + self.assertEqual(kwargs["video_structure"], "scene") + self.assertEqual(kwargs["persona"], "bestie") + self.assertEqual(kwargs["total_duration"], 30) + self.assertIn("口播", kwargs["user_prompt"]) + self.assertIn("场景种草", kwargs["user_prompt"]) + self.assertIn("闺蜜种草", kwargs["user_prompt"]) + + @patch("apps.projects.services.quick_create.get_quick_script_model") + @patch("apps.projects.services.quick_create.stream_script_agent") + def test_consume_script_keeps_wizard_combo(self, stream_fn, get_model): + get_model.return_value = self.model + self.product.category = "食品饮料" + self.product.save(update_fields=["category", "updated_at"]) + self.project.metadata = { + "wizard": { + "presentation_format": "oral", + "video_structure": "pain", + "persona": "reviewer", + "total_duration": 15, + "aspect_ratio": "9:16", + } + } + self.project.save(update_fields=["metadata", "updated_at"]) + stream_fn.return_value = iter([]) + with self.assertRaises(ValueError): + _consume_script_agent(self.job) + kwargs = stream_fn.call_args.kwargs + self.assertEqual(kwargs["presentation_format"], "oral") + self.assertEqual(kwargs["video_structure"], "pain") + self.assertEqual(kwargs["persona"], "reviewer") + self.assertEqual(kwargs["total_duration"], 15) + @patch("apps.projects.services.quick_create.get_quick_script_model") @patch("apps.projects.services.quick_create.stream_script_agent") def test_consume_script_does_not_hit_db_on_every_sse_frame(self, stream_fn, get_model): diff --git a/core/backend/apps/projects/views.py b/core/backend/apps/projects/views.py index a6bc52f..187e3de 100644 --- a/core/backend/apps/projects/views.py +++ b/core/backend/apps/projects/views.py @@ -21,6 +21,7 @@ from apps.ai.script_agent import ( SEGMENT_DURATION_MIN, combo_keys, coerce_total_duration, + recommend_script_setup, stream_script_agent, ) from apps.ai.services import ( @@ -607,6 +608,11 @@ class ProjectViewSet(TeamScopedViewSetMixin, ModelViewSet): 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) + category = str(request.data.get("category") or "").strip() + if not category: + return Response({"detail": "请选择商品品类"}, status=status.HTTP_400_BAD_REQUEST) + if category not in Product.ECOMMERCE_CATEGORIES: + return Response({"detail": "请选择有效的商品品类"}, status=status.HTTP_400_BAD_REQUEST) with transaction.atomic(): assets = list(reused_assets) @@ -628,6 +634,7 @@ class ProjectViewSet(TeamScopedViewSetMixin, ModelViewSet): created_by=request.user, title=name, business_type=Product.BusinessType.ECOMMERCE, + category=category, cover_asset=assets[0], ) ProductImage.objects.bulk_create( @@ -641,6 +648,7 @@ class ProjectViewSet(TeamScopedViewSetMixin, ModelViewSet): for index, asset in enumerate(assets) ] ) + rec = recommend_script_setup(category, name) project = Project.objects.create( team=team, created_by=request.user, @@ -657,9 +665,9 @@ class ProjectViewSet(TeamScopedViewSetMixin, ModelViewSet): "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", + "presentation_format": rec["format"], + "video_structure": rec["structure"], + "persona": rec["persona"], }, }, ) @@ -697,9 +705,10 @@ class ProjectViewSet(TeamScopedViewSetMixin, ModelViewSet): def _quick_job_queryset(self): return ( - QuickCreateJob.objects.select_related("project__product", "project__timeline") + QuickCreateJob.objects.select_related("project__product", "project__product__cover_asset", "project__timeline") .prefetch_related( "project__product__images__asset__files", + "project__product__cover_asset__files", "project__script_versions", "project__base_asset_groups", "project__storyboard_shots__adopted_version__asset__files", diff --git a/core/frontend/src/App.tsx b/core/frontend/src/App.tsx index 9b28beb..57c46f8 100644 --- a/core/frontend/src/App.tsx +++ b/core/frontend/src/App.tsx @@ -32,6 +32,7 @@ import { FreeCreatePage, QuickCreatePage, VideoRemixPage, + VideoReplacePage, ImageWorkbenchPage, LibraryPage, MessagesPage, @@ -1105,6 +1106,17 @@ export function App() { ); case "videoRemix": return m.capability === "text" && m.status === "active")} onNotify={(type, text) => setNotice({ type, text })} onBack={() => goBack("projects")} navigate={navigate} />; + case "videoReplace": + return ( + setNotice({ type, text })} + onBack={() => goBack(entryOrigin("projects"))} + onTaskSettled={refreshFreeCreateShell} + navigate={navigate} + /> + ); case "imageOptimize": return goBack("assetFactory")} navigate={navigate} onGenerate={generateImages} onResume={resumeImages} onNotify={(type, text) => setNotice({ type, text })} />; case "modelPhoto": diff --git a/core/frontend/src/api.ts b/core/frontend/src/api.ts index b33860f..e5f9eef 100644 --- a/core/frontend/src/api.ts +++ b/core/frontend/src/api.ts @@ -301,8 +301,9 @@ export const api = { revokeInvitation(id: string) { return request(`/api/auth/team/invitations/${id}/revoke/`, { method: "POST" }); }, - products() { - return request>("/api/products/"); + products(pageSize?: number) { + const query = pageSize ? `?page_size=${pageSize}` : ""; + return request>(`/api/products/${query}`); }, product(id: string) { return request(`/api/products/${id}/`); diff --git a/core/frontend/src/components/IconKitSvg.tsx b/core/frontend/src/components/IconKitSvg.tsx index d422381..85b3690 100644 --- a/core/frontend/src/components/IconKitSvg.tsx +++ b/core/frontend/src/components/IconKitSvg.tsx @@ -31,6 +31,7 @@ const iconPaths: Record = { film: '', wand: '', scan: '', + replace: '', swap: '', arrowRight: '' }; diff --git a/core/frontend/src/components/app-shell.tsx b/core/frontend/src/components/app-shell.tsx index f4407c9..6b0577c 100644 --- a/core/frontend/src/components/app-shell.tsx +++ b/core/frontend/src/components/app-shell.tsx @@ -18,6 +18,7 @@ const SHELL_COMMANDS: Command[] = [ { 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: "video-replace", group: "导航", label: "视频复刻", sub: "保留参考视频的人物、场景与节奏,复刻出自己的带货内容", page: "videoReplace", icon: "replace", key: "E" }, { id: "library", group: "导航", label: "成品库", sub: "素材、人物、场景、成片统一管理", page: "library", icon: "folder", key: "A" }, { id: "team", group: "导航", label: "团队", sub: "成员、权限、额度、协作记录", page: "team", icon: "users" }, { id: "account", group: "导航", label: "账单库", sub: "余额、充值、账单流水", page: "account", icon: "creditCard" }, @@ -272,7 +273,7 @@ export function topModuleForPage(page: Page): TopModule | null { || page === "modelPhotoDemoB" || page === "platformCover" ) return "image"; - if (page === "projects" || page === "projectWizard" || page === "quickCreate" || page === "pipeline" || page === "freeCreate" || page === "videoRemix") return "video"; + if (page === "projects" || page === "projectWizard" || page === "quickCreate" || page === "pipeline" || page === "freeCreate" || page === "videoRemix" || page === "videoReplace") return "video"; return null; } @@ -404,6 +405,7 @@ const PAGE_TO_NAV: Partial> = { platformCover: "assetFactory", freeCreate: "freeCreate", videoRemix: "projects", + videoReplace: "projects", library: "library", team: "team", account: "account", diff --git a/core/frontend/src/components/product-create-drawer.tsx b/core/frontend/src/components/product-create-drawer.tsx index b3161e5..11c1e37 100644 --- a/core/frontend/src/components/product-create-drawer.tsx +++ b/core/frontend/src/components/product-create-drawer.tsx @@ -1,14 +1,17 @@ import { useEffect, useRef, useState } from "react"; import type { ChangeEvent, DragEvent } from "react"; import { createPortal } from "react-dom"; -import { ArrowLeft, Check, ChevronDown, Image as ImageIcon, Plus, ShieldCheck, Trash2, Upload, X } from "lucide-react"; +import { ArrowLeft, Check, ChevronDown, ChevronRight, Image as ImageIcon, Plus, ShieldCheck, Trash2, Upload, X } from "lucide-react"; import { api } from "../api"; import { useBodyScrollLock, ConfirmModal } from "./overlays"; import type { Product } from "../types"; import { BUSINESS_TYPES, BUSINESS_TYPE_KEYS, + PC_CAT_MORE, + PC_CAT_MORE_LABEL, PC_CAT_OPTIONS, + PC_CAT_PRIMARY, catOptionsFor, type BusinessType, } from "../product-business"; @@ -20,7 +23,6 @@ export type ProductCreatePayload = { title: string; business_type?: BusinessType; category: string; - target_audience?: string; selling_points?: Array<{ title: string; detail: string; sort_order: number }>; cover_asset?: string; images?: Array<{ asset: string; sort_order: number; is_primary?: boolean }>; @@ -51,7 +53,6 @@ export function ProductCreateDrawer({ open, close, onCreate, onCreated, catOptio const [title, setTitle] = useState(""); const [businessType, setBusinessType] = useState("ecommerce"); const [category, setCategory] = useState(""); - const [target, setTarget] = useState(""); const [bullets, setBullets] = useState([""]); const [images, setImages] = useState([]); const [dragOver, setDragOver] = useState(false); @@ -60,6 +61,7 @@ export function ProductCreateDrawer({ open, close, onCreate, onCreated, catOptio const [saving, setSaving] = useState(false); const [galleryOpen, setGalleryOpen] = useState(false); const [openSelect, setOpenSelect] = useState<"" | "type" | "cat">(""); + const [catMoreOpen, setCatMoreOpen] = useState(false); const [toast, setToast] = useState<{ title: string; sub: string } | null>(null); const [confirmExit, setConfirmExit] = useState(false); const imgInputRef = useRef(null); @@ -84,7 +86,6 @@ export function ProductCreateDrawer({ open, close, onCreate, onCreated, catOptio setTitle(""); setBusinessType("ecommerce"); setCategory(""); - setTarget(""); setBullets([""]); setImages((list) => { list.forEach((item) => URL.revokeObjectURL(item.url)); return []; }); setDragOver(false); @@ -92,6 +93,7 @@ export function ProductCreateDrawer({ open, close, onCreate, onCreated, catOptio setCategoryError(false); setGalleryOpen(false); setOpenSelect(""); + setCatMoreOpen(false); } useEffect(() => { @@ -114,7 +116,7 @@ export function ProductCreateDrawer({ open, close, onCreate, onCreated, catOptio }, [openSelect]); function isDirty() { - return Boolean(title.trim() || businessType !== "ecommerce" || category || target.trim() || bullets.some((b) => b.trim()) || images.length); + return Boolean(title.trim() || businessType !== "ecommerce" || category || bullets.some((b) => b.trim()) || images.length); } function changeBusinessType(next: BusinessType) { @@ -227,7 +229,6 @@ export function ProductCreateDrawer({ open, close, onCreate, onCreated, catOptio title: name, business_type: businessType, category: category || activeCatOptions[0], - target_audience: target.trim() || undefined, selling_points: finalBullets.map((item, index) => ({ title: item, detail: item, sort_order: index })), ...(images[0]?.assetId ? { @@ -299,31 +300,57 @@ export function ProductCreateDrawer({ open, close, onCreate, onCreated, catOptio
选择品类 *必选 -
-
- {activeCatOptions.map((option) => ( + {isLocal ? activeCatOptions.map((option) => ( - ))} + )) : ( + <> + {PC_CAT_PRIMARY.map((option) => ( + + ))} + + {catMoreOpen ? PC_CAT_MORE.map((option) => ( + + )) : null} + + )}
- -
diff --git a/core/frontend/src/main.tsx b/core/frontend/src/main.tsx index a0d5820..3704378 100644 --- a/core/frontend/src/main.tsx +++ b/core/frontend/src/main.tsx @@ -15,6 +15,7 @@ import "./settings-page.css"; import "./ai-tools-page.css"; import "./free-create-page.css"; import "./video-remix-page.css"; +import "./video-replace-page.css"; import "./product-create-page.css"; import "./project-wizard-page.css"; import "./quick-create-page.css"; diff --git a/core/frontend/src/product-business.ts b/core/frontend/src/product-business.ts index 7c3365b..391640a 100644 --- a/core/frontend/src/product-business.ts +++ b/core/frontend/src/product-business.ts @@ -9,11 +9,19 @@ export type BusinessType = keyof typeof BUSINESS_TYPES; export const BUSINESS_TYPE_KEYS = Object.keys(BUSINESS_TYPES) as BusinessType[]; -/** 电商品类 · 新建抽屉 / 详情编辑共用 */ -export const PC_CAT_OPTIONS = ["美妆个护", "服饰内衣", "食品饮料", "家居家电", "数码 3C", "个护清洁", "运动户外", "母婴亲子"]; +/** 电商品类 · 常用项,新建抽屉第一层 */ +export const PC_CAT_PRIMARY = ["美妆个护", "食品饮料", "服饰鞋包", "家居日用", "数码家电", "母婴玩具", "运动健康"]; + +/** 电商品类 · 不常用,点「更多品类」再展开 */ +export const PC_CAT_MORE = ["珠宝配饰", "汽车用品", "宠物用品", "图书文教", "五金农资", "其他商品"]; + +export const PC_CAT_MORE_LABEL = "更多品类"; + +/** 电商品类全集 · 筛选 / 详情编辑 / 提交校验用,不含「更多品类」这个入口文案 */ +export const PC_CAT_OPTIONS = [...PC_CAT_PRIMARY, ...PC_CAT_MORE]; /** 本地生活品类 · 团购/到店核销走这条,不跟电商 SKU 混在一个下拉里 */ -export const PC_LOCAL_CAT_OPTIONS = ["餐饮美食", "休闲娱乐", "丽人美发", "酒店民宿", "生活服务", "运动健身", "亲子玩乐", "团购套餐"]; +export const PC_LOCAL_CAT_OPTIONS = ["餐饮美食", "休闲娱乐", "丽人美发", "酒店民宿", "生活服务", "运动健身", "亲子玩乐", "团购套餐", "更多品类"]; export function isLocalLife(product?: { business_type?: string } | null): boolean { return product?.business_type === "local_life"; diff --git a/core/frontend/src/product-create-page.css b/core/frontend/src/product-create-page.css index 32eeb55..d09a9b0 100644 --- a/core/frontend/src/product-create-page.css +++ b/core/frontend/src/product-create-page.css @@ -418,6 +418,8 @@ display: grid; gap: 3px; padding: 6px; + max-height: min(360px, 60vh); + overflow-y: auto; border: 1px solid rgba(34, 42, 54, 0.10); border-radius: 11px; background: #fff; @@ -433,6 +435,10 @@ pointer-events: auto; transform: translateY(0); } +.pcd-select.is-more-open .pcd-select-menu { + max-height: min(640px, 78vh); + padding-bottom: 10px; +} .pcd-select-option { min-height: 38px; padding: 0 10px; @@ -447,6 +453,23 @@ } .pcd-select-option:hover { background: #e8f2ff; } .pcd-select-option.selected { color: var(--klein); background: #f3f7ff; font-weight: 600; } +.pcd-select-more-toggle { + display: flex; + align-items: center; + justify-content: space-between; + color: var(--pcd-muted); +} +.pcd-select-more-toggle svg { + width: 14px; + height: 14px; + flex: 0 0 auto; + color: var(--pcd-muted); + transition: transform 180ms ease; +} +.pcd-select-more-toggle.is-open, +.pcd-select-more-toggle.is-open svg { color: var(--accent-black); } +.pcd-select-more-toggle.is-open svg { transform: rotate(90deg); } +.pcd-select-more-item { padding-left: 22px; } .pcd-block { margin-top: 8px; padding-top: 22px; border-top: 1px solid rgba(34, 42, 54, 0.08); } .pcd-block-head { diff --git a/core/frontend/src/quick-create-page.css b/core/frontend/src/quick-create-page.css index 37c68f9..9bb0582 100644 --- a/core/frontend/src/quick-create-page.css +++ b/core/frontend/src/quick-create-page.css @@ -15,6 +15,25 @@ .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: 20px; } .quick-create-page .quick-form-copy + .quick-field { margin-top: 17px; } +.quick-create-page .quick-cat-grid { + display: grid; + grid-template-columns: repeat(4, minmax(0, 1fr)); + gap: 8px; +} +.quick-create-page .quick-cat-chip { + width: 100%; + justify-content: center; +} +.quick-create-page .quick-cat-chip:disabled { + cursor: not-allowed; + opacity: .55; + transform: none; +} +.quick-create-page .quick-cat-more svg { + flex: 0 0 auto; + transition: transform 180ms ease; +} +.quick-create-page .quick-cat-more.is-open svg { transform: rotate(90deg); } .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; } @@ -76,7 +95,7 @@ .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-result-thumb img,.quick-create-page .quick-video-result-thumb video { width: 100%; height: 100%; display: block; object-fit: cover; pointer-events: none; } .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; } diff --git a/core/frontend/src/routes/dashboard.tsx b/core/frontend/src/routes/dashboard.tsx index 1cfb0c5..285c55a 100644 --- a/core/frontend/src/routes/dashboard.tsx +++ b/core/frontend/src/routes/dashboard.tsx @@ -40,7 +40,7 @@ const CREATE_GROUPS: Array<{ hint: "复用成熟视频的镜头表达与节奏", cards: [ { title: "提炼提示词", desc: "上传参考视频,提炼可编辑提示词", tone: "subtle", page: "videoRemix", icon: "scan" }, - { title: "视频复刻", desc: "保留参考视频的人物、场景与节奏,复刻出自己的带货内容", tone: "subtle", page: "freeCreate", icon: "replace" }, + { title: "视频复刻", desc: "保留参考视频的人物、场景与节奏,复刻出自己的带货内容", tone: "subtle", page: "videoReplace", icon: "replace" }, ], }, ]; diff --git a/core/frontend/src/routes/index.ts b/core/frontend/src/routes/index.ts index 638fe01..fe2193e 100644 --- a/core/frontend/src/routes/index.ts +++ b/core/frontend/src/routes/index.ts @@ -13,4 +13,5 @@ export { AssetFactoryPage, ImageWorkbenchPage, ModelPhotoDemoPage } from "./ai-t export { FreeCreatePage } from "./free-create"; export { QuickCreatePage } from "./quick-create"; export { VideoRemixPage } from "./video-remix"; +export { VideoReplacePage } from "./video-replace"; export { SettingsPage } from "./settings"; diff --git a/core/frontend/src/routes/pipeline.tsx b/core/frontend/src/routes/pipeline.tsx index 0faa549..975e4de 100644 --- a/core/frontend/src/routes/pipeline.tsx +++ b/core/frontend/src/routes/pipeline.tsx @@ -1450,10 +1450,9 @@ export function PipelinePage(props: { const recommended = useMemo( () => recommendSetup({ category: setupProduct?.category, - targetAudience: setupProduct?.target_audience, title: setupProduct?.title, }), - [setupProduct?.category, setupProduct?.target_audience, setupProduct?.title] + [setupProduct?.category, setupProduct?.title] ); const wizard = project.metadata?.wizard; const [setupOpen, setSetupOpen] = useState(false); @@ -1464,7 +1463,9 @@ export function PipelinePage(props: { const [setupStructure, setSetupStructure] = useState( coerceVideoStructure(wizard?.video_structure, recommended.structure) ); - const [setupPersona, setSetupPersona] = useState(coercePersona(wizard?.persona, SETUP_PERSONA_KEYS[0] || "urban")); + const [setupPersona, setSetupPersona] = useState( + coercePersona(wizard?.persona, recommended.persona || SETUP_PERSONA_KEYS[0] || "urban") + ); const [setupDuration, setSetupDuration] = useState(() => { if (typeof wizard?.total_duration === "number") return clampDuration(wizard.total_duration); // 兼容一期向导存的字符串档位("0-30" → 30) @@ -1476,12 +1477,16 @@ export function PipelinePage(props: { const setupTouched = useRef(false); const wizardHasCombo = Boolean(wizard?.presentation_format && wizard?.video_structure); const wizardHasDuration = typeof wizard?.total_duration === "number" || Boolean(wizard?.duration); + const wizardHasPersona = Boolean(wizard?.persona); useEffect(() => { - if (setupTouched.current || wizardHasCombo) return; - setSetupFormat(recommended.format); - setSetupStructure(recommended.structure); - if (!wizardHasDuration) setSetupDuration(recommended.duration); - }, [recommended.format, recommended.structure, recommended.duration, wizardHasCombo, wizardHasDuration]); + if (setupTouched.current) return; + if (!wizardHasCombo) { + setSetupFormat(recommended.format); + setSetupStructure(recommended.structure); + if (!wizardHasDuration) setSetupDuration(recommended.duration); + } + if (!wizardHasPersona) setSetupPersona(recommended.persona); + }, [recommended.format, recommended.structure, recommended.persona, recommended.duration, wizardHasCombo, wizardHasDuration, wizardHasPersona]); // ── 5.2 换商品重跑 ── 新建向导选的套路模板由后端回填进 metadata.wizard,这里只读不写 const templateName = typeof wizard?.template_name === "string" ? wizard.template_name : ""; @@ -1875,7 +1880,7 @@ export function PipelinePage(props: { `${base}\n以上是我拆解一条参考视频得到的分镜稿。请照搬它的镜头顺序、每镜时长比例、景别、机位、运镜、人物动作和声音层次,` + `把人物、商品、品牌、台词全部换成我自己的商品;参考视频里出现的商品、品牌、人物一律不要保留。` + `写 visual 时把每镜的景别、机位、运镜、动作、表情、音效、背景音乐、字幕、备注折进导演说明书,不要压成一句画面摘要。` - + `原稿里标注「无 / 不可见 / 听不清 / 存疑」的地方不要编造,由你按我的商品补全。目标人群:${personaLabel}`, + + `原稿里标注「无 / 不可见 / 听不清 / 存疑」的地方不要编造,由你按我的商品补全。人物设定:${personaLabel}`, `上传视频提炼 · ${combo}`, "video", ); @@ -1891,7 +1896,7 @@ export function PipelinePage(props: { // 上传脚本:先识别原稿结构,再补齐钩子 / 卖点证明 / 转化,而不是照抄一遍 await runScriptGeneration( `${base}\n以上是我已有的脚本。请先识别它的结构与叙述顺序,尽量保留原意与原有表达,再据此整理成镜头脚本;` - + `其中开头钩子、卖点的证明方式、结尾转化引导如有缺失或偏弱,请补齐补强。目标人群:${personaLabel}`, + + `其中开头钩子、卖点的证明方式、结尾转化引导如有缺失或偏弱,请补齐补强。人物设定:${personaLabel}`, `上传脚本 · ${combo}`, "manual", ); @@ -3326,11 +3331,11 @@ export function PipelinePage(props: {
{STRUCTURE_HINT[setupStructure]}
-
根据商品目标人群推荐
+
{recommended.reason} · {WIZ_PERSONA_LABEL[recommended.persona] || recommended.persona}
{/* 时长:15/30/45/60;每镜固定 15 秒 */}
@@ -540,15 +544,15 @@ export function ProductCreateUploadPage({ onCreate, onBack }: { onCreate: (paylo
- {/* 卖点 & 人群 */} + {/* 卖点 */}
-

卖点 & 人群

+

核心卖点

选填 · 推荐
- 填上这两项,后续 AI 生脚本(痛点种草 / 剧情带货 等模板)质量明显更高 —— 系统会用卖点构造钩子,用人群定语气。现在不填也可以,做视频项目时仍可补。 + 填写卖点后,后续 AI 生脚本(痛点种草 / 剧情带货 等模板)会更准确 —— 系统会用卖点构造钩子和证明过程。
@@ -570,10 +574,6 @@ export function ProductCreateUploadPage({ onCreate, onBack }: { onCreate: (paylo
-
- - setAudience(event.target.value)} placeholder="例: 22-32 岁女性、敏感肌、办公室通勤" /> -
{/* 底部操作 */} @@ -598,7 +598,7 @@ export function ProductCreateUploadPage({ onCreate, onBack }: { onCreate: (paylo } // 商品详情页 · 从 public/exact/product-detail.html 忠实转写。 -// 名称 / 品类 / 目标人群 / 卖点 来自 product;商品图网格 / AI 素材卡 / 视频项目卡 +// 名称 / 品类 / 卖点 来自 product;商品图网格 / AI 素材卡 / 视频项目卡 // 已接入真实数据(product.images + 团队 assets + 该商品 projects),保持设计稿像素布局。 // 素材审核状态(只读,反映团队审核结果,非用户可点切换) type PdAssetStatus = "pass" | "fail" | "pending"; @@ -913,13 +913,11 @@ export function ProductDetailPage({ product, projects, initialTab = "assets", na const realCat = product.category || ""; const realType = (product.business_type === "local_life" ? "local_life" : "ecommerce") as BusinessType; const isLocal = realType === "local_life"; - const realTarget = product.target_audience || ""; const realBullets = product.selling_points.map((point) => point.title); const [name, setName] = useState(realName); const [cat, setCat] = useState(realCat); const [bizType, setBizType] = useState(realType); - const [target, setTarget] = useState(realTarget); const [points, setPoints] = useState(realBullets); // R104:显式「添加卖点」按钮追加一条空的可编辑卖点行(行内回车同样追加), @@ -939,7 +937,6 @@ export function ProductDetailPage({ product, projects, initialTab = "assets", na title: name, business_type: bizType, category: cat, - target_audience: target, // 卖点整体替换(后端 present 即覆盖):发 {title,detail,sort_order} 不带 id; // 过滤空行(点了「添加卖点」还没填的行),否则后端 title 非空校验会退 400 selling_points: points.map((item) => item.trim()).filter(Boolean).map((item, index) => ({ title: item, detail: item, sort_order: index })) as Product["selling_points"] @@ -951,7 +948,6 @@ export function ProductDetailPage({ product, projects, initialTab = "assets", na setName(realName); setCat(realCat); setBizType(realType); - setTarget(realTarget); setPoints(realBullets); setEditing(false); setActionsLockH(null); @@ -1085,13 +1081,6 @@ export function ProductDetailPage({ product, projects, initialTab = "assets", na
-
-
目标人群
-
- {realTarget} - setTarget(event.target.value)} /> -
-
核心卖点
diff --git a/core/frontend/src/routes/projects.tsx b/core/frontend/src/routes/projects.tsx index eaa965c..4f17e1f 100644 --- a/core/frontend/src/routes/projects.tsx +++ b/core/frontend/src/routes/projects.tsx @@ -442,7 +442,7 @@ const VIDEO_MAKE: Array<{ title: string; desc: string; page: Page; image: string const VIDEO_TOOLS: Array<{ title: string; desc: string; page: Page; image: string }> = [ { title: "自由生成", desc: "使用提示词、参考图片与素材库,自由控制画面和生成参数。", page: "freeCreate", image: "/assets/yz/video-free.jpg" }, { title: "提炼提示词", desc: "从参考视频中提炼可编辑的视频生成提示词。", page: "videoRemix", image: "/assets/yz/video-remix.jpg" }, - { title: "视频复刻", desc: "保留参考视频的人物、场景与节奏,复刻出自己的带货内容。", page: "freeCreate", image: "/assets/yz/video-replace.jpg" }, + { title: "视频复刻", desc: "保留参考视频的人物、场景与节奏,复刻出自己的带货内容。", page: "videoReplace", image: "/assets/yz/video-replace.jpg" }, ]; function isQuickCreateProject(project: Project) { diff --git a/core/frontend/src/routes/quick-create.tsx b/core/frontend/src/routes/quick-create.tsx index b2c61af..1f89f97 100644 --- a/core/frontend/src/routes/quick-create.tsx +++ b/core/frontend/src/routes/quick-create.tsx @@ -4,6 +4,7 @@ import { ArrowLeft, Boxes, Clapperboard, + ChevronRight, ImagePlus, LayoutPanelTop, RefreshCw, @@ -30,6 +31,12 @@ import { type BillingRates, } from "../components/free-create/constants"; import type { NavigateFn } from "./route-config"; +import { + PC_CAT_MORE, + PC_CAT_MORE_LABEL, + PC_CAT_OPTIONS, + PC_CAT_PRIMARY, +} from "../product-business"; const PROGRESS_STEPS = [ { label: "脚本", icon: ScrollText }, @@ -85,6 +92,10 @@ function historyBadge(item: QuickCreateJob) { return "未完成"; } +function historyCover(item: QuickCreateJob) { + return item.product_images?.[0]?.url || ""; +} + function historyVideoUrl(item: QuickCreateJob) { return item.result?.video_url || item.result?.video_segments?.[0]?.video_url || ""; } @@ -113,6 +124,8 @@ export function QuickCreatePage({ modelConfigs: ModelConfig[]; }) { const [name, setName] = useState(""); + const [category, setCategory] = useState(""); + const [catMoreOpen, setCatMoreOpen] = useState(false); const [images, setImages] = useState([]); const [savedImages, setSavedImages] = useState>([]); const [sourceProductId, setSourceProductId] = useState(""); @@ -126,7 +139,7 @@ export function QuickCreatePage({ const [unavailableMessage, setUnavailableMessage] = useState(""); const [pollEpoch, setPollEpoch] = useState(0); const [history, setHistory] = useState([]); - const [playing, setPlaying] = useState<{ url: string; poster: string; title: string } | null>(null); + const [playing, setPlaying] = useState<{ url: string; title: string } | null>(null); const videoConfigs = useMemo( () => modelConfigs.filter((config) => config.capability === "video" && config.status === "active"), [modelConfigs], @@ -182,6 +195,10 @@ export function QuickCreatePage({ productPrefillDoneRef.current = true; setName((current) => current || product.title || ""); setSourceProductId((current) => current || product.id); + if (product.category && PC_CAT_OPTIONS.includes(product.category)) { + setCategory((current) => current || product.category); + setCatMoreOpen(PC_CAT_MORE.includes(product.category)); + } setSavedImages((current) => { if (current.length) return current; return (product.images || []) @@ -299,12 +316,12 @@ export function QuickCreatePage({ return () => window.removeEventListener("keydown", onKey); }, [playing]); - function playClip(url?: string, poster?: string, title?: string) { + function playClip(url?: string, title?: string) { if (!url) { onNotify?.("info", "视频还不能播放,请稍后再试"); return; } - setPlaying({ url, poster: poster || "", title: title || "预览视频" }); + setPlaying({ url, title: title || "预览视频" }); } function selectImages(files: FileList | null) { @@ -372,6 +389,10 @@ export function QuickCreatePage({ onNotify?.("info", "请先填写商品名称并上传商品图片"); return; } + if (!category) { + onNotify?.("info", "请选择商品品类,脚本会按品类来写"); + return; + } setSubmitting(true); setJob(null); setServiceUnavailable(false); @@ -381,6 +402,7 @@ export function QuickCreatePage({ try { const form = new FormData(); form.append("name", name.trim()); + form.append("category", category); form.append("aspect_ratio", aspectRatio); form.append("resolution", resolution); form.append("total_duration", String(totalDuration)); @@ -433,6 +455,8 @@ export function QuickCreatePage({ function clearDraft() { setName(""); + setCategory(""); + setCatMoreOpen(false); setImages([]); setSavedImages([]); setSourceProductId(""); @@ -501,6 +525,7 @@ export function QuickCreatePage({ const reviewBlocked = isReviewFailure(job?.error_message); const displayUrls = [...savedImages.map((image) => image.url), ...imagePreviews]; const imageCount = savedImages.length + images.length; + const canStart = Boolean(name.trim() && category && imageCount && selectedVideoModel) && !reviewBlocked; const canRetry = Boolean(name.trim() && imageCount && selectedVideoModel) && !reviewBlocked; const activePhase = submitting ? 0 : Math.max(0, Math.min(4, job?.phase_index ?? 0)); const result = job?.result; @@ -511,7 +536,6 @@ export function QuickCreatePage({ : []; const clipUrls = new Set(videoClips.map((clip) => clip.video_url).filter(Boolean)); const mergedUrl = result?.final_video_url || (result?.video_url && !clipUrls.has(result.video_url) ? result.video_url : ""); - const posterFallback = displayUrls[0] || ""; const sceneCount = Math.max(1, Math.round(totalDuration / 15)); const videoEstimate = estimateCost( selectedVideoModel, @@ -547,6 +571,46 @@ export function QuickCreatePage({ setName(event.target.value)} placeholder="例如:净颜精华、轻醒咖啡" disabled={isGenerating} /> +
+ 商品品类必选 · 写脚本时会用到 +
+ {PC_CAT_PRIMARY.map((option) => ( + + ))} + + {catMoreOpen ? PC_CAT_MORE.map((option) => ( + + )) : null} +
+
+
商品图片必填 · 最多9张
@@ -613,7 +677,7 @@ export function QuickCreatePage({ {cancelling ? "正在取消…" : "取消生成"} ) : ( - )} @@ -646,24 +710,23 @@ export function QuickCreatePage({
- {videoClips.map((clip, index) => ( + {videoClips.map((clip, index) => { + const clipUrl = clip.video_url || result?.video_url || ""; + return ( - ))} + ); + })}
@@ -676,7 +739,7 @@ export function QuickCreatePage({ {videoClips.length > 1 ? ( mergedUrl ? ( - ) : ( @@ -743,7 +806,7 @@ export function QuickCreatePage({ {history.length ? (
{history.map((item) => { - const poster = item.result?.poster_url || ""; + const cover = historyCover(item); const videoUrl = historyVideoUrl(item); 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)); @@ -754,11 +817,11 @@ export function QuickCreatePage({ @@ -798,7 +861,7 @@ export function QuickCreatePage({ {playing.title}
-
) : null} diff --git a/core/frontend/src/routes/route-config.ts b/core/frontend/src/routes/route-config.ts index 68aac8a..bfae3f8 100644 --- a/core/frontend/src/routes/route-config.ts +++ b/core/frontend/src/routes/route-config.ts @@ -30,6 +30,7 @@ export type Page = | "freeCreate" | "quickCreate" | "videoRemix" + | "videoReplace" | "imageOptimize" | "modelPhoto" | "modelPhotoDemoA" @@ -109,6 +110,7 @@ export const routeLabels: Record = { freeCreate: "自由创作", quickCreate: "极速成片", videoRemix: "提炼提示词", + videoReplace: "视频复刻", imageOptimize: "图片创作", modelPhoto: "模特上身图", modelPhotoDemoA: "模特图方案 A", @@ -126,7 +128,7 @@ export function isPage(value: string): value is Page { export function parentPage(page: Page): Page { if (["productDetail", "productCreateUpload"].includes(page)) return "products"; - if (["projectWizard", "freeCreate", "quickCreate", "videoRemix", "pipeline"].includes(page)) return "projects"; + if (["projectWizard", "freeCreate", "quickCreate", "videoRemix", "videoReplace", "pipeline"].includes(page)) return "projects"; if (["imageOptimize", "modelPhoto", "modelPhotoDemoA", "modelPhotoDemoB", "platformCover"].includes(page)) { return "assetFactory"; } @@ -173,6 +175,7 @@ export function resolveRoute(): ResolvedRoute { return { page: "quickCreate", authMode: "login", productId: search.get("product_id") || undefined, hash }; } if (path === "/video-remix") return { page: "videoRemix", authMode: "login", hash }; + if (path === "/video-replace") return { page: "videoReplace", authMode: "login", hash }; if (path === "/image-optimize") { return { page: "imageOptimize", authMode: "login", productId: search.get("product_id") || undefined, hash }; } @@ -220,6 +223,8 @@ export function pathForPage(page: Page, options: NavigateOptions = {}) { return options.productId ? `/quick-create?product_id=${encodeURIComponent(options.productId)}` : "/quick-create"; case "videoRemix": return "/video-remix"; + case "videoReplace": + return "/video-replace"; case "imageOptimize": return options.productId ? `/image-optimize?product_id=${encodeURIComponent(options.productId)}` : "/image-optimize"; case "modelPhoto": diff --git a/core/frontend/src/routes/video-replace.tsx b/core/frontend/src/routes/video-replace.tsx new file mode 100644 index 0000000..d51d7e9 --- /dev/null +++ b/core/frontend/src/routes/video-replace.tsx @@ -0,0 +1,768 @@ +import { useEffect, useMemo, useRef, useState } from "react"; +import { + ArrowLeft, + ArrowUpRight, + Check, + ChevronRight, + Clapperboard, + Download, + FileVideo2, + ImagePlus, + LibraryBig, + Package, + RefreshCw, + Replace, + Upload, + X, +} from "lucide-react"; +import { api, ApiError } from "../api"; +import { MediaLightbox } from "../components/overlays"; +import { + DEFAULT_BILLING_RATES, + FC_MODELS, + IMAGE_TYPES, + MAX_IMAGES, + checkRefFile, + estimateCost, + isInFlight, + type BillingRates, +} from "../components/free-create/constants"; +import type { FreeVideoRef, FreeVideoTask, ModelConfig, Product } from "../types"; +import type { NavigateFn } from "./route-config"; + +const JOB_KEY = "airshelf:video-replace-job"; +const REMIX_MARK = "[视频复刻]"; +const VIDEO_ACCEPT = "video/mp4,video/quicktime"; + +type ProductSource = "library" | "temporary" | ""; + +function readJobId() { + try { + return localStorage.getItem(JOB_KEY) || ""; + } catch { + return ""; + } +} + +function rememberJob(id: string) { + try { + localStorage.setItem(JOB_KEY, id); + } catch { + /* 无痕模式忽略 */ + } +} + +function forgetJob() { + try { + localStorage.removeItem(JOB_KEY); + } catch { + /* 无痕模式忽略 */ + } +} + +function fileKey(file: File) { + return `${file.name}:${file.size}:${file.lastModified}`; +} + +function ratioFromSize(width: number, height: number) { + if (!width || !height) return "9:16"; + const r = width / height; + if (Math.abs(r - 9 / 16) < 0.08) return "9:16"; + if (Math.abs(r - 16 / 9) < 0.08) return "16:9"; + if (Math.abs(r - 1) < 0.08) return "1:1"; + if (Math.abs(r - 3 / 4) < 0.08) return "3:4"; + if (Math.abs(r - 4 / 3) < 0.08) return "4:3"; + if (Math.abs(r - 21 / 9) < 0.12) return "21:9"; + return width > height ? "16:9" : "9:16"; +} + +function ratioCopy(ratio: string) { + if (ratio === "9:16") return "竖屏 9:16"; + if (ratio === "16:9") return "横屏 16:9"; + return ratio; +} + +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 clampDuration(seconds: number) { + const rounded = Math.round(Number(seconds) || 15); + return Math.min(15, Math.max(4, rounded || 15)); +} + +function isRemixTask(task: FreeVideoTask) { + return (task.prompt || "").startsWith(REMIX_MARK); +} + +function productNameFromPrompt(prompt?: string) { + const match = (prompt || "").match(/商品:([^\n。]+)/); + return (match?.[1] || "").trim(); +} + +function remixTitle(task?: Partial | null) { + const name = productNameFromPrompt(task?.prompt); + return name ? `${name}视频复刻` : "视频复刻预览"; +} + +function remixSourceLabel(task?: Partial | null) { + const prompt = task?.prompt || ""; + const name = productNameFromPrompt(prompt); + if (/商品库/.test(prompt) && name) return `商品库:${name}`; + return "临时商品素材"; +} + +function productCover(product: Product) { + return product.cover_preview_url || product.images?.find((image) => image.is_primary)?.preview_url || product.images?.[0]?.preview_url || ""; +} + +function productImageCount(product: Product) { + return product.images?.filter((image) => image.asset || image.preview_url).length || 0; +} + +function buildPrompt(productName: string, fromLibrary: boolean) { + const source = fromLibrary ? "商品库中的" : "本次上传的"; + return `${REMIX_MARK} 商品:${productName}。使用${source}商品参考图,保留参考视频的人物、场景、镜头运动、剪辑节奏与口播氛围,将画面中的原商品完整替换为该商品。商品外观、材质、包装必须与参考图一致,不要改变原片构图和人物表演。`; +} + +export function VideoReplacePage({ + products: initialProducts = [], + modelConfigs = [], + onNotify, + onBack, + onTaskSettled, +}: { + products?: Product[]; + modelConfigs?: ModelConfig[]; + onNotify: (type: "success" | "error" | "info", text: string) => void; + onBack: () => void; + onTaskSettled?: () => void; + navigate?: NavigateFn; +}) { + const [products, setProducts] = useState(initialProducts); + const [videoFile, setVideoFile] = useState(null); + const [videoRef, setVideoRef] = useState(null); + const [videoUploading, setVideoUploading] = useState(false); + const [videoMeta, setVideoMeta] = useState({ duration: 0, width: 0, height: 0 }); + const [source, setSource] = useState(""); + const [selectedProduct, setSelectedProduct] = useState(null); + const [tempFiles, setTempFiles] = useState([]); + const [tempPreviews, setTempPreviews] = useState([]); + const [libraryOpen, setLibraryOpen] = useState(false); + const [pendingProductId, setPendingProductId] = useState(""); + const [jobId, setJobId] = useState(readJobId); + const [job, setJob] = useState(null); + const [history, setHistory] = useState([]); + const [submitting, setSubmitting] = useState(false); + const [billingRates, setBillingRates] = useState(DEFAULT_BILLING_RATES); + const [playing, setPlaying] = useState<{ url: string; title: string } | null>(null); + const videoInputRef = useRef(null); + const tempInputRef = useRef(null); + const completedNoticeRef = useRef(""); + + const videoConfigs = useMemo( + () => modelConfigs.filter((config) => config.capability === "video" && config.status === "active"), + [modelConfigs], + ); + const preferredModel = useMemo( + () => videoConfigs.find((config) => config.name === FC_MODELS[0].name) || videoConfigs[0], + [videoConfigs], + ); + + const productName = source === "library" + ? (selectedProduct?.title || "") + : tempFiles[0] + ? (tempFiles.length > 1 + ? `${tempFiles[0].name.replace(/\.[^.]+$/, "") || "临时商品素材"}(${tempFiles.length}张参考图)` + : (tempFiles[0].name.replace(/\.[^.]+$/, "") || "临时商品素材")) + : ""; + const productReady = source === "library" ? Boolean(selectedProduct) : tempFiles.length > 0; + const libraryPreview = selectedProduct ? productCover(selectedProduct) : ""; + const aspectRatio = ratioFromSize(videoMeta.width, videoMeta.height); + const outputDuration = clampDuration(videoMeta.duration || 15); + const estimated = estimateCost(preferredModel, { + ratio: aspectRatio, + resolution: "720p", + duration: outputDuration, + refs: [ + ...(videoRef ? [{ type: "video", duration: videoRef.duration || videoMeta.duration }] : []), + ...((source === "library" ? (selectedProduct?.images || []).slice(0, MAX_IMAGES) : tempFiles).map(() => ({ type: "image" }))), + ], + }, billingRates); + const points = estimated.points || 220; + const generating = Boolean(job && isInFlight(job.status)) || submitting || videoUploading; + const hasResult = Boolean(job && job.status === "succeeded" && job.video_url); + const panelClass = [ + "video-result-panel replace-result-panel", + generating ? "is-generating" : "", + hasResult ? "has-result" : "", + ].filter(Boolean).join(" "); + const generateLabel = generating + ? "正在复刻…" + : hasResult + ? `再次复刻 · 消耗 ${points} 积分` + : `开始复刻 · 消耗 ${points} 积分`; + + const loadHistory = async () => { + try { + const data = await api.freeVideoTasks(0, 50); + setHistory((data.results || []).filter((item) => isRemixTask(item) && item.status === "succeeded")); + } catch { + /* 历史失败不挡当前复刻 */ + } + }; + + useEffect(() => { + void api.products(100).then((payload) => setProducts(payload.results || [])).catch(() => undefined); + 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 loadHistory(); + }, []); + + useEffect(() => { + if (!initialProducts.length) return; + setProducts((current) => (current.length ? current : initialProducts)); + }, [initialProducts]); + + useEffect(() => { + const urls = tempFiles.map((file) => URL.createObjectURL(file)); + setTempPreviews(urls); + return () => urls.forEach((url) => URL.revokeObjectURL(url)); + }, [tempFiles]); + + useEffect(() => { + if (!libraryOpen) return; + const previous = document.body.classList.contains("asset-library-open"); + document.body.classList.add("asset-library-open"); + return () => { + if (!previous) document.body.classList.remove("asset-library-open"); + }; + }, [libraryOpen]); + + useEffect(() => { + if (!jobId) return; + let cancelled = false; + let timer = 0; + const poll = async () => { + try { + const data = await api.pollFreeVideo(jobId); + if (cancelled) return; + setJob(data.task); + if (isInFlight(data.task.status)) { + timer = window.setTimeout(poll, 2500); + return; + } + if (data.task.status === "succeeded") { + if (completedNoticeRef.current !== data.task.id) { + completedNoticeRef.current = data.task.id; + onNotify("success", "视频复刻成片已生成"); + onTaskSettled?.(); + } + void loadHistory(); + } else if (data.task.status === "failed") { + onNotify("error", data.task.error_message || "视频复刻未完成,请重试"); + onTaskSettled?.(); + } + forgetJob(); + } catch (error) { + if (cancelled) return; + if (error instanceof ApiError && error.status === 404) { + setJob(null); + setJobId(""); + forgetJob(); + return; + } + timer = window.setTimeout(poll, 8000); + } + }; + void poll(); + return () => { + cancelled = true; + window.clearTimeout(timer); + }; + }, [jobId, onNotify, onTaskSettled]); + + const pickVideo = async (file: File | null) => { + if (!file) return; + const check = await checkRefFile(file); + if (!check.ok) { + onNotify("error", check.error); + return; + } + if (check.type !== "video") { + onNotify("error", "只支持 mp4 / mov 视频"); + return; + } + setVideoFile(file); + setVideoUploading(true); + setJob((current) => (current && isInFlight(current.status) ? current : null)); + if (job && !isInFlight(job.status)) setJob(null); + const form = new FormData(); + form.append("file", file); + try { + const uploaded = await api.uploadFreeVideoRef(form); + setVideoRef({ + url: uploaded.url, + type: "video", + role: "reference_video", + label: "参考视频", + thumb_url: uploaded.thumb_url, + duration: uploaded.duration || check.duration, + asset_id: uploaded.asset_id, + source: "upload", + }); + setVideoMeta({ + duration: uploaded.duration || check.duration || 0, + width: uploaded.width || 0, + height: uploaded.height || 0, + }); + } catch (error) { + setVideoFile(null); + setVideoRef(null); + onNotify("error", error instanceof Error ? error.message : "参考视频上传失败"); + } finally { + setVideoUploading(false); + } + }; + + const addTempImages = (files: FileList | null) => { + const incoming = [...(files || [])].filter((file) => IMAGE_TYPES.includes(file.type)); + if (!incoming.length) { + onNotify("error", "请选择 JPG、PNG 或 WebP 图片"); + return; + } + setTempFiles((current) => { + const existing = new Set(current.map(fileKey)); + const unique = incoming.filter((file) => { + const key = fileKey(file); + if (existing.has(key)) return false; + existing.add(key); + return true; + }); + const room = Math.max(0, MAX_IMAGES - current.length); + if (room === 0) { + onNotify("info", "最多上传9张商品图片"); + return current; + } + if (!unique.length) { + onNotify("info", "所选图片已在九宫格中"); + return current; + } + if (unique.length > room) onNotify("info", `最多上传9张图片,已保留前${room}张`); + return [...current, ...unique.slice(0, room)]; + }); + setSource("temporary"); + setSelectedProduct(null); + if (job && !isInFlight(job.status)) setJob(null); + }; + + const confirmLibraryProduct = () => { + const product = products.find((item) => item.id === pendingProductId); + if (!product) { + onNotify("info", "请先选择或上传商品素材"); + return; + } + setSelectedProduct(product); + setSource("library"); + setTempFiles([]); + setLibraryOpen(false); + setPendingProductId(""); + if (job && !isInFlight(job.status)) setJob(null); + onNotify("success", `已选择商品:${product.title}`); + }; + + const startGeneration = async () => { + if (!videoFile || !productReady || generating) return; + if (!preferredModel) { + onNotify("error", "暂无可用视频模型"); + return; + } + setSubmitting(true); + try { + let imageRefs: FreeVideoRef[] = []; + if (source === "library" && selectedProduct) { + imageRefs = (selectedProduct.images || []) + .filter((image) => image.asset || image.preview_url) + .slice(0, MAX_IMAGES) + .map((image, index) => ({ + url: image.preview_url || "", + type: "image" as const, + role: "reference_image", + label: `${selectedProduct.title}${index + 1}`, + asset_id: image.asset, + source: "asset" as const, + })); + if (!imageRefs.length && (selectedProduct.cover_asset || productCover(selectedProduct))) { + imageRefs = [{ + url: productCover(selectedProduct), + type: "image", + role: "reference_image", + label: selectedProduct.title, + asset_id: selectedProduct.cover_asset || undefined, + source: selectedProduct.cover_asset ? "asset" : "upload", + }]; + } + if (!imageRefs.length) { + onNotify("error", "这个商品还没有可用图片"); + return; + } + } else { + const uploaded: FreeVideoRef[] = []; + for (const file of tempFiles.slice(0, MAX_IMAGES)) { + const form = new FormData(); + form.append("file", file); + const data = await api.uploadFreeVideoRef(form); + uploaded.push({ + url: data.url, + type: "image", + role: "reference_image", + label: data.name || file.name, + thumb_url: data.thumb_url || data.url, + asset_id: data.asset_id, + source: "upload", + }); + } + imageRefs = uploaded; + } + if (!videoRef) { + onNotify("error", "请先上传参考视频"); + return; + } + const prompt = buildPrompt(productName.replace(/(\d+张参考图)$/, ""), source === "library"); + const data = await api.submitFreeVideo({ + prompt, + mode: "universal", + model: preferredModel.name, + aspect_ratio: aspectRatio, + resolution: "720p", + duration: outputDuration, + seed: -1, + generate_audio: true, + references: [videoRef, ...imageRefs], + }); + setJob(data.task); + setJobId(data.task.id); + rememberJob(data.task.id); + completedNoticeRef.current = ""; + onNotify("success", "视频复刻任务已开始"); + if (!isInFlight(data.task.status) && data.task.status === "succeeded") { + onNotify("success", "视频复刻成片已生成"); + void loadHistory(); + forgetJob(); + } + } catch (error) { + onNotify("error", error instanceof Error ? error.message : "视频复刻提交失败"); + } finally { + setSubmitting(false); + } + }; + + const downloadVideo = (url: string, title: string) => { + if (!url) return; + const link = document.createElement("a"); + link.href = url; + link.download = `${title || "视频复刻"}.mp4`; + link.rel = "noopener"; + document.body.appendChild(link); + link.click(); + link.remove(); + onNotify("success", "已开始下载视频复刻成片"); + }; + + const openHistory = (item: FreeVideoTask) => { + setJob(item); + setJobId(""); + forgetJob(); + }; + + const cells = Array.from({ length: 9 }, (_, index) => tempFiles[index] || null); + + return ( +
+
+
+
+
+ +
+

视频复刻

+
+
+
+ +
+
+

准备复刻素材

+
+
+ 1. 参考视频 + MP4 / MOV · 最长 60 秒 +
+ +
+ +
+
+ 2. 选择自己的商品 + 请选择一种方式 +
+
+ + + +
+
+ +
+ +
+
+ + +
+ +
+
+

已完成的视频复刻项目

+ {history.length} 个项目 +
+ {history.length === 0 ? ( +
还没有完成的视频复刻项目
+ ) : ( +
+ {history.map((item) => ( +
+
{ + if (!item.video_url) return; + setPlaying({ url: item.video_url, title: remixTitle(item) }); + }} + > + {item.thumbnail_url ? {`${remixTitle(item)}封面`} : null} + {formatClock(item.duration)} +
+
+ 已完成 +

{remixTitle(item)}

+

参考视频 {item.duration} 秒 · {remixSourceLabel(item)} · {item.aspect_ratio} · {item.resolution}

+
+ +
+ ))} +
+ )} +
+
+
+ +
+ + +
+ {products.length === 0 ? ( +
还没有商品,先去商品库创建一个
+ ) : products.map((product) => ( + + ))} +
+
+ + +
+ +
+ + setPlaying(null)} + /> +
+ ); +} diff --git a/core/frontend/src/script-setup.ts b/core/frontend/src/script-setup.ts index 4095972..c2ca5c9 100644 --- a/core/frontend/src/script-setup.ts +++ b/core/frontend/src/script-setup.ts @@ -106,52 +106,78 @@ export function durationWarning(structure: VideoStructure, seconds: number): str return `${VIDEO_STRUCTURES[structure]}低于 ${min} 秒会压不住,建议调长`; } -// ── 2.3 按商品信息推荐默认组合 ── +// ── 2.3 按商品分类推荐默认组合 ── -type Combo = { format: PresentationFormat; structure: VideoStructure }; +type Combo = { format: PresentationFormat; structure: VideoStructure; persona: string }; -/** 品类关键词 → 推荐组合。命中第一条即用,没命中走兜底。 */ +/** 与商品库品类一一对应。改这里时同步 backend script_agent.recommend_script_setup。 */ +const CATEGORY_COMBOS: Record = { + 美妆个护: { format: "oral", structure: "contrast", persona: "bestie" }, + 食品饮料: { format: "oral", structure: "scene", persona: "bestie" }, + 服饰鞋包: { format: "oral", structure: "scene", persona: "urban" }, + 家居日用: { format: "oral", structure: "pain", persona: "mom" }, + 数码家电: { format: "oral", structure: "review", persona: "reviewer" }, + 母婴玩具: { format: "oral", structure: "pain", persona: "mom" }, + 运动健康: { format: "oral", structure: "scene", persona: "urban" }, + 珠宝配饰: { format: "oral", structure: "scene", persona: "ceo" }, + 汽车用品: { format: "oral", structure: "review", persona: "reviewer" }, + 宠物用品: { format: "oral", structure: "pain", persona: "mom" }, + 图书文教: { format: "oral", structure: "knowledge", persona: "urban" }, + 五金农资: { format: "oral", structure: "review", persona: "reviewer" }, + 其他商品: { format: "oral", structure: "pain", persona: "urban" }, +}; + +/** 旧品类名 / 标题关键词兜底。命中第一条即用。 */ const CATEGORY_RULES: ReadonlyArray<{ keywords: string[]; combo: Combo }> = [ - { keywords: ["美妆", "护肤", "彩妆", "面膜", "精华", "洗护", "个护"], combo: { format: "oral", structure: "contrast" } }, - { keywords: ["保健", "营养", "膳食", "益生菌", "维生素"], combo: { format: "oral", structure: "review" } }, - { keywords: ["数码", "3c", "电子", "电器", "手机", "耳机", "相机", "工具"], combo: { format: "oral", structure: "review" } }, - { keywords: ["食品", "零食", "饮料", "咖啡", "茶", "生鲜", "酒"], combo: { format: "vlog", structure: "scene" } }, - { keywords: ["服饰", "女装", "男装", "服装", "鞋", "包", "配饰", "内衣"], combo: { format: "vlog", structure: "scene" } }, - { keywords: ["家居", "家纺", "收纳", "厨具", "清洁", "日用"], combo: { format: "drama", structure: "pain" } }, - { keywords: ["母婴", "宝宝", "儿童", "宠物"], combo: { format: "drama", structure: "pain" } }, - { keywords: ["户外", "露营", "运动", "健身"], combo: { format: "vlog", structure: "scene" } }, + { keywords: ["美妆", "护肤", "彩妆", "面膜", "精华", "洗护", "个护"], combo: CATEGORY_COMBOS["美妆个护"] }, + { keywords: ["保健", "营养", "膳食", "益生菌", "维生素"], combo: { format: "oral", structure: "review", persona: "reviewer" } }, + { keywords: ["数码", "3c", "电子", "电器", "手机", "耳机", "相机", "工具", "家电"], combo: CATEGORY_COMBOS["数码家电"] }, + { keywords: ["食品", "零食", "饮料", "咖啡", "茶", "生鲜", "酒"], combo: CATEGORY_COMBOS["食品饮料"] }, + { keywords: ["服饰", "女装", "男装", "服装", "鞋", "包", "配饰", "内衣"], combo: CATEGORY_COMBOS["服饰鞋包"] }, + { keywords: ["家居", "家纺", "收纳", "厨具", "清洁", "日用"], combo: CATEGORY_COMBOS["家居日用"] }, + { keywords: ["母婴", "宝宝", "儿童", "玩具"], combo: CATEGORY_COMBOS["母婴玩具"] }, + { keywords: ["宠物"], combo: CATEGORY_COMBOS["宠物用品"] }, + { keywords: ["户外", "露营", "运动", "健身"], combo: CATEGORY_COMBOS["运动健康"] }, ]; -const FALLBACK_COMBO: Combo = { format: "oral", structure: "pain" }; +const FALLBACK_COMBO: Combo = { format: "oral", structure: "pain", persona: "urban" }; -/** 按商品品类 + 人群推荐一组「表现形式 × 视频结构 × 时长」。纯启发式,用户随时可改。 */ -export function recommendSetup(input: { category?: string; targetAudience?: string; title?: string }): Combo & { +/** 按商品品类推荐「表现形式 × 视频结构 × 人物 × 时长」。用户随时可改。 */ +export function recommendSetup(input: { category?: string; title?: string }): Combo & { duration: number; reason: string; } { - const haystack = [input.category, input.title, input.targetAudience] + const category = (input.category || "").trim(); + const haystack = [category, input.title] .filter(Boolean) .join(" ") .toLowerCase(); let combo = FALLBACK_COMBO; let reason = "商品信息不足,先给一组最通用的"; - for (const rule of CATEGORY_RULES) { - const hit = rule.keywords.find((word) => haystack.includes(word.toLowerCase())); - if (hit) { - combo = rule.combo; - reason = `按「${hit}」品类推荐`; - break; + if (category && CATEGORY_COMBOS[category]) { + combo = CATEGORY_COMBOS[category]; + reason = `按「${category}」品类推荐`; + } else { + for (const rule of CATEGORY_RULES) { + const hit = rule.keywords.find((word) => haystack.includes(word.toLowerCase())); + if (hit) { + combo = rule.combo; + reason = `按「${hit}」品类推荐`; + break; + } } } - // 防御:规则表若被改出禁用组合,这里兜住 - const structure = isForbidden(combo.format, combo.structure) - ? allowedStructures(combo.format)[0] + // 自动推荐只给口播;Vlog / 短剧留给用户自己选。 + const format: PresentationFormat = "oral"; + const structure = isForbidden(format, combo.structure) + ? allowedStructures(format)[0] : combo.structure; return { - format: combo.format, + format, structure, - duration: recommendDuration(combo.format, structure), + persona: combo.persona, + duration: recommendDuration(format, structure), reason, }; } diff --git a/core/frontend/src/video-replace-page.css b/core/frontend/src/video-replace-page.css new file mode 100644 index 0000000..c7cb6dc --- /dev/null +++ b/core/frontend/src/video-replace-page.css @@ -0,0 +1,1125 @@ +/* 视频复刻 · 对照影擎 replace-page。不重写共享 .btn/.pill/.input/.chip/.textarea。 */ +.vrep-page { + --klein: #002fa7; + --klein-hover: #002680; + --muted: #6f747c; + --text: #17181a; + --shadow-card: 0 8px 20px rgba(20, 27, 38, 0.09); + width: 100%; + max-width: none; + box-sizing: border-box; + margin: -24px -28px -60px; + padding: 52px clamp(40px, 3.2vw, 68px) 48px; +} + +@media (max-width: 1100px) { + .vrep-page { margin: -28px -24px -48px; } +} + +.vrep-page .vrep-inner { + width: min(1560px, 100%); + margin: 0 auto; +} + +.vrep-page .video-tool-page { + min-height: calc(100vh - 116px - 100px); +} + +.vrep-page .page-header { + min-height: 76px; + display: flex; + align-items: flex-start; + justify-content: space-between; + gap: 30px; + margin-bottom: 34px; +} + +.vrep-page .image-title-row { + display: flex; + align-items: flex-start; + gap: 13px; +} + +.vrep-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, 0.46); + border-radius: 12px; + color: var(--klein); + background: rgba(255, 255, 255, 0.82); + cursor: pointer; + box-shadow: 0 5px 14px rgba(0, 47, 167, 0.07); + transition: background-color 180ms ease, transform 180ms ease; +} + +.vrep-page .image-back-button:hover { + transform: translateX(-2px); + border-color: var(--klein); + background: rgba(0, 47, 167, 0.055); +} + +.vrep-page .image-back-button svg { width: 19px; height: 19px; } + +.vrep-page .page-heading h1 { + margin: 0 0 10px; + font-size: 32px; + line-height: 1.2; + font-weight: 600; + letter-spacing: -.02em; + color: var(--text); +} + +.vrep-page .video-flow-grid { + display: grid; + grid-template-columns: minmax(0, 1.08fr) minmax(360px, 0.92fr); + gap: 22px; + margin-top: 26px; +} + +.vrep-page .video-flow-panel, +.vrep-page .video-result-panel { + padding: 24px; + border: 1px solid rgba(34, 42, 54, 0.1); + border-radius: 16px; + background: rgba(255, 255, 255, 0.92); + box-shadow: var(--shadow-card); +} + +.vrep-page .replace-flow-panel, +.vrep-page .replace-result-panel { + position: relative; + overflow: hidden; +} + +.vrep-page .replace-flow-panel::before, +.vrep-page .replace-result-panel::before { + content: ""; + position: absolute; + inset: 0 0 auto; + z-index: 3; + height: 3px; +} + +.vrep-page .replace-flow-panel::before { + background: var(--klein); +} + +.vrep-page .replace-result-panel::before { + background: #101012; + transition: background-color 220ms ease; +} + +.vrep-page .replace-result-panel.has-result::before { + background: var(--klein); +} + +.vrep-page .replace-result-panel { + display: flex; + flex-direction: column; + background: + radial-gradient(circle at 78% 16%, rgba(0, 47, 167, 0.1), transparent 28%), + linear-gradient(rgba(34, 42, 54, 0.025) 1px, transparent 1px), + linear-gradient(90deg, rgba(34, 42, 54, 0.025) 1px, transparent 1px), + rgba(252, 253, 255, 0.96); + background-size: auto, 38px 38px, 38px 38px, auto; +} + +.vrep-page .video-flow-panel h2, +.vrep-page .video-result-panel h2 { + margin: 0; + font-size: 20px; + font-weight: 600; + color: var(--text); +} + +.vrep-page .video-flow-step { + margin-top: 22px; +} + +.vrep-page .video-flow-step-head { + display: flex; + align-items: center; + justify-content: space-between; + gap: 12px; + margin-bottom: 9px; +} + +.vrep-page .video-flow-step-head strong { + font-size: 14px; + font-weight: 600; + color: var(--text); +} + +.vrep-page .video-flow-step-head span { + color: var(--muted); + font-size: 11px; +} + +.vrep-page .video-upload-field { + min-height: 148px; + display: grid; + place-items: center; + padding: 22px; + border: 1px dashed rgba(0, 47, 167, 0.3); + border-radius: 13px; + color: #42506a; + background: rgba(0, 47, 167, 0.04); + text-align: center; + cursor: pointer; + transition: border-color 160ms ease, background 160ms ease; +} + +.vrep-page .video-upload-field:hover, +.vrep-page .video-upload-field.has-file { + border-color: var(--klein); + background: rgba(0, 47, 167, 0.075); +} + +.vrep-page .video-upload-field > span { + display: grid; + place-items: center; + gap: 8px; + min-width: 0; +} + +.vrep-page .video-upload-field svg { + width: 28px; + height: 28px; + color: var(--klein); +} + +.vrep-page .video-upload-field strong { + color: var(--text); + font-size: 14px; + font-weight: 500; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + max-width: 100%; +} + +.vrep-page .video-upload-field small { + color: var(--muted); + font-size: 12px; +} + +.vrep-page .video-flow-actions { + display: flex; + align-items: center; + justify-content: space-between; + gap: 16px; + margin-top: 22px; +} + +.vrep-page .primary-action, +.vrep-page .secondary-action { + display: inline-flex; + align-items: center; + justify-content: center; + gap: 9px; + border-radius: 11px; + cursor: pointer; + font: inherit; + transition: transform 180ms ease, background-color 180ms ease, box-shadow 180ms ease; +} + +.vrep-page .primary-action { + min-width: 158px; + height: 50px; + padding: 0 22px; + color: #fff; + background: var(--klein); + border: 0; + box-shadow: 0 9px 18px rgba(0, 47, 167, 0.18); +} + +.vrep-page .primary-action:hover:not(:disabled) { + transform: translateY(-2px); + background: var(--klein-hover); +} + +.vrep-page .primary-action:disabled { + opacity: 0.38; + transform: none; + box-shadow: none; + cursor: not-allowed; +} + +.vrep-page .secondary-action { + min-width: 132px; + height: 46px; + padding: 0 18px; + color: var(--text); + border: 1px solid rgba(28, 34, 43, 0.12); + background: rgba(34, 42, 54, 0.05); +} + +.vrep-page .secondary-action:hover:not(:disabled) { + background: rgba(34, 42, 54, 0.10); +} + +.vrep-page .primary-action svg, +.vrep-page .secondary-action svg { + width: 19px; + height: 19px; +} + +.vrep-page .product-replace-options { + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: 12px; +} + +.vrep-page .replace-product-method { + position: relative; + min-width: 0; + min-height: 112px; + display: grid; + grid-template-columns: 46px minmax(0, 1fr) auto; + align-items: center; + gap: 13px; + padding: 16px; + border: 1px solid rgba(34, 42, 54, 0.11); + border-radius: 13px; + color: var(--text); + background: rgba(249, 250, 252, 0.92); + font: inherit; + text-align: left; + cursor: pointer; + overflow: hidden; + transition: border-color 170ms ease, background-color 170ms ease, box-shadow 170ms ease, transform 170ms ease; +} + +.vrep-page .replace-product-method::after { + content: ""; + position: absolute; + inset: 0; + z-index: 1; + opacity: 0; + pointer-events: none; + background: linear-gradient(90deg, rgba(255, 255, 255, 0.24), rgba(255, 255, 255, 0.08)); + transition: opacity 180ms ease; +} + +.vrep-page .replace-product-method.has-product-preview::after { + opacity: 1; +} + +.vrep-page .replace-product-method > :not(.replace-product-method-background) { + position: relative; + z-index: 2; +} + +.vrep-page .replace-product-method-background { + position: absolute; + inset: 0; + z-index: 0; + width: 100%; + height: 100%; + display: block; + object-fit: cover; + opacity: 0; + pointer-events: none; + transition: opacity 180ms ease; +} + +.vrep-page .replace-product-method.has-product-preview .replace-product-method-background { + opacity: 0.5; +} + +.vrep-page .replace-product-method.has-product-preview .replace-product-method-copy { + padding: 8px 10px; + border-radius: 9px; + background: rgba(255, 255, 255, 0.78); + backdrop-filter: blur(5px); +} + +.vrep-page .replace-product-method:hover { + border-color: rgba(0, 47, 167, 0.28); + transform: translateY(-1px); +} + +.vrep-page .replace-product-method.active { + border-color: var(--klein); + background: #f1f5ff; + box-shadow: inset 0 0 0 1px var(--klein); +} + +.vrep-page .replace-product-method-icon { + width: 46px; + height: 46px; + display: grid; + place-items: center; + border: 1px solid rgba(0, 47, 167, 0.12); + border-radius: 13px; + color: var(--klein); + background: #fff; + box-shadow: 0 8px 18px rgba(0, 47, 167, 0.07); +} + +.vrep-page .replace-product-method-icon svg { + width: 20px; + height: 20px; +} + +.vrep-page .replace-product-method-copy { + min-width: 0; +} + +.vrep-page .replace-product-method-copy strong, +.vrep-page .replace-product-method-copy small { + display: block; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.vrep-page .replace-product-method-copy strong { + font-size: 13px; +} + +.vrep-page .replace-product-method-copy small { + margin-top: 6px; + color: var(--muted); + font-size: 11px; +} + +.vrep-page .replace-product-method > svg { + width: 17px; + height: 17px; + color: #8b94a4; +} + +.vrep-page .replace-temporary-preview { + display: none; +} + +.vrep-page .replace-temporary-method.has-images { + min-height: 224px; + grid-template-columns: minmax(0, 1fr); + padding: 12px; +} + +.vrep-page .replace-temporary-method.has-images > .replace-product-method-icon, +.vrep-page .replace-temporary-method.has-images > .replace-product-method-copy, +.vrep-page .replace-temporary-method.has-images > svg { + display: none; +} + +.vrep-page .replace-temporary-method.has-images .replace-temporary-preview { + width: 100%; + display: grid; + grid-template-columns: minmax(0, 1fr) minmax(100px, 0.72fr); + align-items: stretch; + gap: 10px; +} + +.vrep-page .replace-temporary-grid { + aspect-ratio: 1; + display: grid; + grid-template-columns: repeat(3, minmax(0, 1fr)); + grid-template-rows: repeat(3, minmax(0, 1fr)); + gap: 4px; + padding: 6px; + border: 1px solid rgba(34, 42, 54, 0.09); + border-radius: 11px; + background: #fff; +} + +.vrep-page .replace-temporary-cell { + position: relative; + min-width: 0; + overflow: hidden; + border: 1px solid rgba(34, 42, 54, 0.09); + border-radius: 7px; + background: #e9edf4; +} + +.vrep-page .replace-temporary-cell.empty { + border-style: dashed; + border-color: rgba(0, 47, 167, 0.13); + background: rgba(248, 250, 255, 0.72); +} + +.vrep-page .replace-temporary-cell img { + width: 100%; + height: 100%; + display: block; + object-fit: cover; +} + +.vrep-page .replace-temporary-remove { + position: absolute; + top: 3px; + right: 3px; + width: 18px; + height: 18px; + display: grid; + place-items: center; + border: 0; + border-radius: 5px; + color: #fff; + background: rgba(16, 16, 18, 0.72); + font: inherit; + font-size: 13px; + line-height: 1; + cursor: pointer; +} + +.vrep-page .replace-temporary-more { + display: grid; + place-items: center; + align-content: center; + gap: 7px; + padding: 12px; + border: 1px dashed rgba(0, 47, 167, 0.28); + border-radius: 11px; + color: #38455d; + background: #f3f7ff; + text-align: center; +} + +.vrep-page .replace-temporary-more > span:first-child { + width: 36px; + height: 36px; + display: grid; + place-items: center; + border-radius: 11px; + color: var(--klein); + background: #fff; +} + +.vrep-page .replace-temporary-more svg { + width: 18px; + height: 18px; +} + +.vrep-page .replace-temporary-more strong { + font-size: 12px; +} + +.vrep-page .replace-temporary-count { + color: var(--muted); + font-size: 10px; +} + +.vrep-page .replace-temporary-clear { + padding: 5px 8px; + border: 0; + border-radius: 6px; + color: #778196; + background: rgba(255, 255, 255, 0.78); + font: inherit; + font-size: 10px; + cursor: pointer; +} + +.vrep-page .replace-generate-action { + justify-content: flex-end; +} + +.vrep-page .replace-generate-action .primary-action { + min-width: 230px; +} + +.vrep-page .replace-result-actions { + justify-content: flex-end; + gap: 12px; +} + +.vrep-page .replace-result-panel.has-result .video-analysis-result { + flex: 1; + min-height: 470px; + display: flex; + flex-direction: column; + justify-content: space-between; + gap: 24px; +} + +.vrep-page .replace-result-panel.has-result .replace-preview, +.vrep-page .replace-result-panel.has-result .replace-result-actions { + margin-top: 0; +} + +.vrep-page .replace-placeholder-visual { + position: relative; + width: 104px; + height: 104px; + display: grid; + place-items: center; + margin-bottom: 26px; + border: 1px solid rgba(16, 16, 18, 0.1); + border-radius: 28px; + background: rgba(255, 255, 255, 0.9); + box-shadow: 0 12px 28px rgba(22, 45, 92, 0.07); +} + +.vrep-page .replace-placeholder-visual::before, +.vrep-page .replace-placeholder-visual::after { + display: none; +} + +.vrep-page .replace-placeholder-visual > span { + position: relative; + width: 46px; + height: 46px; + display: grid; + place-items: center; + border-radius: 14px; + color: var(--klein); + background: rgba(0, 47, 167, 0.07); +} + +.vrep-page .replace-placeholder-visual svg { + width: 22px; + height: 22px; + color: var(--klein); +} + +.vrep-page .video-result-placeholder, +.vrep-page .video-analysis-result { + min-height: 360px; +} + +.vrep-page .video-result-placeholder { + display: grid; + place-items: center; + color: var(--muted); + text-align: center; +} + +.vrep-page .video-result-placeholder > div { + display: grid; + place-items: center; + gap: 10px; +} + +.vrep-page .replace-result-panel .video-result-placeholder { + min-height: 470px; + flex: 1; +} + +.vrep-page .replace-result-panel .video-result-placeholder > div { + transform: translateY(24px); +} + +.vrep-page .video-analysis-result { + display: none; +} + +.vrep-page .video-result-panel.has-result .video-result-placeholder { + display: none; +} + +.vrep-page .video-result-panel.has-result .video-analysis-result { + display: block; +} + +.vrep-page .replace-generating-state { + min-height: 470px; + flex: 1; + display: none; + place-items: center; + text-align: center; +} + +.vrep-page .replace-result-panel.is-generating .video-result-placeholder, +.vrep-page .replace-result-panel.is-generating .video-analysis-result { + display: none; +} + +.vrep-page .replace-result-panel.is-generating .replace-generating-state { + display: grid; +} + +.vrep-page .replace-generating-content { + width: min(300px, 100%); + display: grid; + justify-items: center; + gap: 10px; +} + +.vrep-page .replace-generating-visual { + position: relative; + width: 136px; + height: 104px; + margin-bottom: 14px; +} + +.vrep-page .replace-generating-frame { + position: absolute; + inset: 4px 18px 18px 4px; + display: grid; + place-items: center; + overflow: hidden; + border: 1px solid rgba(16, 16, 18, 0.11); + border-radius: 20px; + color: #101012; + background: rgba(255, 255, 255, 0.94); + box-shadow: 0 14px 30px rgba(22, 45, 92, 0.08); +} + +.vrep-page .replace-generating-frame svg { + width: 27px; + height: 27px; + stroke-width: 1.7; +} + +.vrep-page .replace-generating-frame::after { + content: ""; + position: absolute; + inset: 0; + background: linear-gradient(110deg, transparent 28%, rgba(0, 47, 167, 0.13) 50%, transparent 72%); + transform: translateX(-130%); + animation: replace-frame-scan 1.35s ease-in-out infinite; +} + +.vrep-page .replace-generating-product { + position: absolute; + right: 0; + bottom: 0; + width: 44px; + height: 44px; + display: grid; + place-items: center; + border: 3px solid #fff; + border-radius: 14px; + color: #fff; + background: var(--klein); + box-shadow: 0 9px 20px rgba(0, 47, 167, 0.2); + animation: replace-product-pulse 1.35s ease-in-out infinite; +} + +.vrep-page .replace-generating-product svg { + width: 19px; + height: 19px; +} + +.vrep-page .replace-generating-content > strong { + color: var(--text); + font-size: 16px; +} + +.vrep-page .replace-generating-content > span { + color: var(--muted); + font-size: 11px; +} + +.vrep-page .replace-generating-bar { + width: 210px; + height: 4px; + margin-top: 8px; + overflow: hidden; + border-radius: 999px; + background: rgba(16, 16, 18, 0.08); +} + +.vrep-page .replace-generating-bar span { + width: 44%; + height: 100%; + display: block; + border-radius: inherit; + background: var(--klein); + animation: replace-progress-slide 1.15s ease-in-out infinite; +} + +@keyframes replace-frame-scan { + 0%, 12% { transform: translateX(-130%); } + 76%, 100% { transform: translateX(130%); } +} + +@keyframes replace-product-pulse { + 0%, 100% { transform: scale(1); } + 50% { transform: scale(1.07); } +} + +@keyframes replace-progress-slide { + 0% { transform: translateX(-115%); } + 100% { transform: translateX(260%); } +} + +@media (prefers-reduced-motion: reduce) { + .vrep-page .replace-generating-frame::after, + .vrep-page .replace-generating-product, + .vrep-page .replace-generating-bar span { + animation: none; + } +} + +.vrep-page .replace-preview { + position: relative; + min-height: 250px; + display: grid; + place-items: center; + margin-top: 18px; + overflow: hidden; + border-radius: 13px; + color: #fff; + background: + linear-gradient(180deg, rgba(12, 17, 27, 0.08), rgba(12, 17, 27, 0.62)), + #101012; + text-align: center; +} + +.vrep-page .replace-preview video, +.vrep-page .replace-preview img { + position: absolute; + inset: 0; + width: 100%; + height: 100%; + display: block; + object-fit: cover; +} + +.vrep-page .replace-preview-copy { + position: relative; + z-index: 1; + align-self: end; + width: 100%; + padding: 26px; + background: linear-gradient(180deg, transparent, rgba(12, 17, 27, 0.72)); +} + +.vrep-page .replace-preview-copy strong { + display: block; + font-size: 18px; +} + +.vrep-page .replace-preview-copy span { + display: block; + margin-top: 7px; + color: rgba(255, 255, 255, 0.76); + font-size: 12px; +} + +.vrep-page .replace-history-section { + margin-top: 28px; +} + +.vrep-page .replace-history-head { + display: flex; + align-items: center; + justify-content: space-between; + gap: 18px; + margin-bottom: 13px; +} + +.vrep-page .replace-history-head h2 { + margin: 0; + font-size: 19px; + font-weight: 600; + color: var(--text); +} + +.vrep-page .replace-history-head span { + color: var(--muted); + font-size: 12px; +} + +.vrep-page .replace-history-list { + display: grid; + gap: 12px; +} + +.vrep-page .replace-history-empty { + padding: 28px 16px; + border: 1px solid rgba(0, 47, 167, 0.12); + border-radius: 15px; + color: var(--muted); + background: rgba(255, 255, 255, 0.72); + font-size: 13px; + text-align: center; +} + +.vrep-page .replace-history-card { + position: relative; + display: grid; + grid-template-columns: 184px minmax(0, 1fr) auto; + align-items: center; + gap: 20px; + padding: 12px; + overflow: hidden; + border: 1px solid rgba(0, 47, 167, 0.12); + border-radius: 15px; + background: rgba(255, 255, 255, 0.9); + box-shadow: 0 12px 30px rgba(22, 45, 92, 0.055); +} + +.vrep-page .replace-history-card::before { + content: ""; + position: absolute; + inset: 0 auto 0 0; + width: 3px; + background: var(--klein); +} + +.vrep-page .replace-history-cover { + position: relative; + height: 108px; + overflow: hidden; + border-radius: 10px; + background: #e9eef8; + cursor: pointer; +} + +.vrep-page .replace-history-cover img, +.vrep-page .replace-history-cover video { + width: 100%; + height: 100%; + display: block; + object-fit: cover; +} + +.vrep-page .replace-history-cover span { + position: absolute; + right: 8px; + bottom: 8px; + padding: 4px 7px; + border-radius: 6px; + color: #fff; + background: rgba(16, 16, 18, 0.76); + font-size: 10px; + font-weight: 700; +} + +.vrep-page .replace-history-copy > span { + display: inline-flex; + padding: 4px 8px; + border-radius: 999px; + color: var(--klein); + background: #eef3ff; + font-size: 10px; + font-weight: 700; +} + +.vrep-page .replace-history-copy h3 { + margin: 9px 0 0; + font-size: 16px; + font-weight: 600; + color: #1d2940; +} + +.vrep-page .replace-history-copy p { + margin: 7px 0 0; + color: var(--muted); + font-size: 11px; +} + +.vrep-page .replace-history-open { + min-height: 38px; + display: inline-flex; + align-items: center; + gap: 7px; + padding: 0 13px; + border: 1px solid rgba(0, 47, 167, 0.22); + border-radius: 9px; + color: var(--klein); + background: #fff; + font: inherit; + font-size: 11px; + font-weight: 650; + cursor: pointer; +} + +.vrep-page .replace-history-open svg { + width: 14px; + height: 14px; +} + +.vrep-page .asset-library-layer { + position: fixed; + inset: 0; + z-index: 90; + visibility: hidden; + opacity: 0; + pointer-events: none; + transition: visibility 0s linear 260ms, opacity 220ms ease; +} + +.vrep-page .asset-library-layer.open { + visibility: visible; + opacity: 1; + pointer-events: auto; + transition-delay: 0s; +} + +.vrep-page .asset-library-scrim { + position: absolute; + inset: 0; + border: 0; + background: rgba(6, 9, 15, 0.58); + cursor: default; +} + +.vrep-page .asset-library-drawer { + position: absolute; + top: 0; + right: 0; + width: min(650px, 48vw); + height: 100%; + display: flex; + flex-direction: column; + overflow: hidden; + background: #fff; + box-shadow: -22px 0 48px rgba(7, 12, 22, 0.2); + transform: translateX(100%); + transition: transform 280ms cubic-bezier(0.22, 1, 0.36, 1); +} + +.vrep-page .asset-library-layer.open .asset-library-drawer { + transform: translateX(0); +} + +.vrep-page .asset-library-head { + min-height: 82px; + display: flex; + align-items: center; + justify-content: space-between; + gap: 18px; + padding: 0 24px; + border-bottom: 1px solid rgba(34, 42, 54, 0.09); +} + +.vrep-page .asset-library-head h2 { + margin: 0 0 4px; + font-size: 21px; +} + +.vrep-page .asset-library-head p { + margin: 0; + color: var(--muted); + font-size: 11px; +} + +.vrep-page .product-drawer-close { + width: 38px; + height: 38px; + display: grid; + place-items: center; + border: 1px solid rgba(34, 42, 54, 0.10); + border-radius: 11px; + color: var(--muted); + background: rgba(34, 42, 54, 0.045); + cursor: pointer; + transition: color 160ms ease, background-color 160ms ease; +} + +.vrep-page .product-drawer-close:hover { + color: var(--text); + background: rgba(34, 42, 54, 0.09); +} + +.vrep-page .product-drawer-close svg { + width: 18px; + height: 18px; +} + +.vrep-page .asset-library-grid { + min-height: 0; + flex: 1; + overflow-y: auto; + display: grid; + grid-template-columns: repeat(3, minmax(0, 1fr)); + align-content: start; + gap: 12px; + padding: 22px 24px; +} + +.vrep-page .asset-library-choice { + position: relative; + min-height: 214px; + overflow: hidden; + border: 1px solid rgba(34, 42, 54, 0.1); + border-radius: 13px; + background: #f7f8fa; + cursor: pointer; + font: inherit; + padding: 0; + transition: border-color 160ms ease, box-shadow 160ms ease, transform 160ms ease; +} + +.vrep-page .asset-library-choice:hover { + transform: translateY(-2px); + border-color: rgba(0, 47, 167, 0.32); + box-shadow: 0 10px 24px rgba(20, 27, 38, 0.08); +} + +.vrep-page .asset-library-choice.selected { + border-color: var(--klein); + box-shadow: 0 0 0 2px rgba(0, 47, 167, 0.1); +} + +.vrep-page .asset-library-choice img { + width: 100%; + height: 164px; + display: block; + object-fit: cover; + background: #e9eef8; +} + +.vrep-page .asset-library-choice > span:not(.asset-choice-check) { + display: grid; + gap: 3px; + padding: 10px; + text-align: left; +} + +.vrep-page .asset-library-choice strong { font-size: 11px; } +.vrep-page .asset-library-choice small { color: var(--muted); font-size: 9px; } + +.vrep-page .asset-choice-check { + position: absolute; + top: 9px; + right: 9px; + width: 24px; + height: 24px; + display: none; + place-items: center; + border-radius: 50%; + color: #fff; + background: var(--klein); +} + +.vrep-page .asset-library-choice.selected .asset-choice-check { display: grid; } +.vrep-page .asset-choice-check svg { width: 13px; height: 13px; } + +.vrep-page .asset-library-footer { + min-height: 82px; + display: flex; + align-items: center; + justify-content: flex-end; + gap: 10px; + padding: 0 24px; + border-top: 1px solid rgba(34, 42, 54, 0.09); + background: #fff; + box-shadow: 0 -10px 24px rgba(20, 27, 38, 0.045); +} + +.vrep-page .asset-library-empty { + grid-column: 1 / -1; + padding: 36px 12px; + color: var(--muted); + font-size: 13px; + text-align: center; +} + +@media (max-width: 1120px) { + .vrep-page .video-flow-grid { grid-template-columns: 1fr; } + .vrep-page .product-replace-options { grid-template-columns: 1fr; } + .vrep-page .replace-history-card { + grid-template-columns: 1fr; + } + .vrep-page .replace-history-open { + justify-self: start; + } + .vrep-page .asset-library-drawer { + width: min(650px, 100vw); + } + .vrep-page .asset-library-grid { + grid-template-columns: repeat(2, minmax(0, 1fr)); + } +}