优化住流程和添加复刻视频页面

This commit is contained in:
Azmat@qq.com
2026-08-26 18:44:03 +08:00
parent 0ee498d807
commit c25060c6c6
30 changed files with 2574 additions and 155 deletions
+135 -23
View File
@@ -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:
+18 -3
View File
@@ -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:
@@ -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("单镜 415 秒,允许不等长", 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")
@@ -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)
@@ -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)
+1 -1
View File
@@ -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, ("穿上", "穿着", "换上", "佩戴", "戴上", "戴着")),
+5
View File
@@ -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 挂载。
+6 -5
View File
@@ -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(
@@ -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)。
@@ -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):
+13 -4
View File
@@ -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",
+12
View File
@@ -32,6 +32,7 @@ import {
FreeCreatePage,
QuickCreatePage,
VideoRemixPage,
VideoReplacePage,
ImageWorkbenchPage,
LibraryPage,
MessagesPage,
@@ -1105,6 +1106,17 @@ export function App() {
);
case "videoRemix":
return <VideoRemixPage textModels={modelConfigs.filter((m) => m.capability === "text" && m.status === "active")} onNotify={(type, text) => setNotice({ type, text })} onBack={() => goBack("projects")} navigate={navigate} />;
case "videoReplace":
return (
<VideoReplacePage
products={products}
modelConfigs={modelConfigs}
onNotify={(type, text) => setNotice({ type, text })}
onBack={() => goBack(entryOrigin("projects"))}
onTaskSettled={refreshFreeCreateShell}
navigate={navigate}
/>
);
case "imageOptimize":
return <ImageWorkbenchPage mode="image" products={products} modelConfigs={modelConfigs} imageProductId={route.productId} initialProductId={activeProductId} onProductChange={setActiveProductId} unreadByProduct={aiUnreadByProduct} onProductViewed={markAiRead} onBack={() => goBack("assetFactory")} navigate={navigate} onGenerate={generateImages} onResume={resumeImages} onNotify={(type, text) => setNotice({ type, text })} />;
case "modelPhoto":
+3 -2
View File
@@ -301,8 +301,9 @@ export const api = {
revokeInvitation(id: string) {
return request<Invitation>(`/api/auth/team/invitations/${id}/revoke/`, { method: "POST" });
},
products() {
return request<Paginated<Product>>("/api/products/");
products(pageSize?: number) {
const query = pageSize ? `?page_size=${pageSize}` : "";
return request<Paginated<Product>>(`/api/products/${query}`);
},
product(id: string) {
return request<Product>(`/api/products/${id}/`);
@@ -31,6 +31,7 @@ const iconPaths: Record<string, string> = {
film: '<rect x="3" y="3" width="18" height="18" rx="2"/><path d="M7 3v18"/><path d="M17 3v18"/><path d="M3 7.5h4"/><path d="M3 16.5h4"/><path d="M17 7.5h4"/><path d="M17 16.5h4"/><path d="M3 12h4"/><path d="M17 12h4"/>',
wand: '<path d="m21.64 3-1.28 1.28a5.5 5.5 0 0 0-7.78 7.78l-8.5 8.5a2.12 2.12 0 0 0 3 3l8.5-8.5a5.5 5.5 0 0 0 7.78-7.78Z"/><path d="m14 7 3 3"/>',
scan: '<path d="M3 7V5a2 2 0 0 1 2-2h2"/><path d="M17 3h2a2 2 0 0 1 2 2v2"/><path d="M21 17v2a2 2 0 0 1-2 2h-2"/><path d="M7 21H5a2 2 0 0 1-2-2v-2"/><path d="M7 12h10"/>',
replace: '<path d="M14 4a2 2 0 0 1 2-2"/><path d="M16 10a2 2 0 0 1-2-2"/><path d="M20 2a2 2 0 0 1 2 2"/><path d="M22 8a2 2 0 0 1-2 2"/><path d="m3 7 3 3 3-3"/><path d="M6 10V5a3 3 0 0 1 3-3h1"/><rect x="2" y="14" width="8" height="8" rx="2"/>',
swap: '<path d="M8 3 4 7l4 4"/><path d="M4 7h16"/><path d="m16 21 4-4-4-4"/><path d="M20 17H4"/>',
arrowRight: '<path d="M5 12h14"/><path d="m12 5 7 7-7 7"/>'
};
+3 -1
View File
@@ -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<Record<Page, Page>> = {
platformCover: "assetFactory",
freeCreate: "freeCreate",
videoRemix: "projects",
videoReplace: "projects",
library: "library",
team: "team",
account: "account",
@@ -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<BusinessType>("ecommerce");
const [category, setCategory] = useState("");
const [target, setTarget] = useState("");
const [bullets, setBullets] = useState<string[]>([""]);
const [images, setImages] = useState<PfImage[]>([]);
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<HTMLInputElement | null>(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
<div className="pcd-field">
<span className="pcd-label"><span> <span className="pcd-req">*</span></span><small></small></span>
<div className={`pcd-select${openSelect === "cat" ? " open" : ""}`}>
<button type="button" className={`pcd-select-trigger${categoryError ? " is-error" : ""}`} aria-haspopup="listbox" aria-expanded={openSelect === "cat"} onClick={() => setOpenSelect((c) => (c === "cat" ? "" : "cat"))}>
<div className={`pcd-select${openSelect === "cat" ? " open" : ""}${catMoreOpen ? " is-more-open" : ""}`}>
<button
type="button"
className={`pcd-select-trigger${categoryError ? " is-error" : ""}`}
aria-haspopup="listbox"
aria-expanded={openSelect === "cat"}
onClick={() => {
if (openSelect === "cat") {
setOpenSelect("");
return;
}
setCatMoreOpen(PC_CAT_MORE.includes(category));
setOpenSelect("cat");
}}
>
<span className={category ? "" : "is-placeholder"}>{category || "请选择商品品类"}</span>
<ChevronDown />
</button>
<div className="pcd-select-menu" role="listbox">
{activeCatOptions.map((option) => (
{isLocal ? activeCatOptions.map((option) => (
<button type="button" className={`pcd-select-option${category === option ? " selected" : ""}`} key={option} onClick={() => { setCategory(option); setCategoryError(false); setOpenSelect(""); }}>
{option}
</button>
)) : (
<>
{PC_CAT_PRIMARY.map((option) => (
<button type="button" className={`pcd-select-option${category === option ? " selected" : ""}`} key={option} onClick={() => { setCategory(option); setCategoryError(false); setOpenSelect(""); }}>
{option}
</button>
))}
<button
type="button"
className={`pcd-select-option pcd-select-more-toggle${catMoreOpen ? " is-open" : ""}`}
aria-expanded={catMoreOpen}
onClick={() => setCatMoreOpen((open) => !open)}
>
<span>{PC_CAT_MORE_LABEL}</span>
<ChevronRight />
</button>
{catMoreOpen ? PC_CAT_MORE.map((option) => (
<button type="button" className={`pcd-select-option pcd-select-more-item${category === option ? " selected" : ""}`} key={option} onClick={() => { setCategory(option); setCategoryError(false); setOpenSelect(""); }}>
{option}
</button>
)) : null}
</>
)}
</div>
</div>
</div>
</div>
<label className="pcd-field">
<span className="pcd-label"><span></span><small></small></span>
<input
value={target}
onChange={(event) => setTarget(event.target.value)}
placeholder={isLocal ? "例如:附近上班族、带娃家庭、周末聚餐" : "例如:22–32 岁女性、敏感肌、通勤人群"}
/>
</label>
<section className="pcd-block" aria-labelledby="productImageTitle">
<div className="pcd-block-head">
<div>
+1
View File
@@ -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";
+11 -3
View File
@@ -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";
+23
View File
@@ -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 {
+20 -1
View File
@@ -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; }
+1 -1
View File
@@ -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" },
],
},
];
+1
View File
@@ -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";
+16 -10
View File
@@ -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<VideoStructure>(
coerceVideoStructure(wizard?.video_structure, recommended.structure)
);
const [setupPersona, setSetupPersona] = useState<string>(coercePersona(wizard?.persona, SETUP_PERSONA_KEYS[0] || "urban"));
const [setupPersona, setSetupPersona] = useState<string>(
coercePersona(wizard?.persona, recommended.persona || SETUP_PERSONA_KEYS[0] || "urban")
);
const [setupDuration, setSetupDuration] = useState<number>(() => {
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;
if (setupTouched.current) return;
if (!wizardHasCombo) {
setSetupFormat(recommended.format);
setSetupStructure(recommended.structure);
if (!wizardHasDuration) setSetupDuration(recommended.duration);
}, [recommended.format, recommended.structure, recommended.duration, wizardHasCombo, wizardHasDuration]);
}
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: {
<div className="setup-rec">{STRUCTURE_HINT[setupStructure]}</div>
<label className="setup-field">
<span className="sf-k"></span>
<select className="setup-select" value={setupPersona} onChange={(e) => setSetupPersona(e.target.value)}>
<select className="setup-select" value={setupPersona} onChange={(e) => { setupTouched.current = true; setSetupPersona(e.target.value); }}>
{SETUP_PERSONA_KEYS.map((k) => <option key={k} value={k}>{WIZ_PERSONA_LABEL[k]}</option>)}
</select>
</label>
<div className="setup-rec"></div>
<div className="setup-rec">{recommended.reason} · {WIZ_PERSONA_LABEL[recommended.persona] || recommended.persona}</div>
{/* 时长:15/30/45/60;每镜固定 15 秒 */}
<label className="setup-field">
<span className="sf-k"></span>
@@ -3343,10 +3348,11 @@ export function PipelinePage(props: {
{/* ← 返回:收起设定卡,回到「脚本辅助生成 / 上传脚本」入口页 */}
<button type="button" className="btn btn-ghost btn-sm" onClick={() => setSetupOpen(false)}> </button>
<button type="button" className="btn btn-ghost btn-sm" title={recommended.reason} onClick={() => {
// 重新推荐:回到按商品信息算出的那一组(不是随机换,随机换等于没推荐)
// 重新推荐:回到按商品品类算出的那一组(不是随机换,随机换等于没推荐)
setupTouched.current = false;
setSetupFormat(recommended.format);
setSetupStructure(recommended.structure);
setSetupPersona(recommended.persona);
setSetupDuration(recommended.duration);
}}></button>
<button type="button" className="btn btn-primary btn-sm" disabled={loading || scriptFileBusy || videoDigestBusy || ((setupSource === "manual" || setupSource === "video") && !chatText.trim())} onClick={() => void runScriptWithSetup()}></button>
+13 -24
View File
@@ -4,10 +4,12 @@ import type { ChangeEvent, CSSProperties, FormEvent, KeyboardEvent } from "react
import { ArrowLeft, Check, ChevronDown, Grid2X2, List, PackagePlus, PackageX, Search, Settings2, Trash2, X } from "lucide-react";
import { ConfirmModal, MediaLightbox, SuccessModal } from "../components/overlays";
import { ReviewBadge, type ReviewStatus } from "../components/review-badge";
import { ProductCreateDrawer, PC_CAT_OPTIONS } from "../components/product-create-drawer";
import { ProductCreateDrawer } from "../components/product-create-drawer";
import {
BUSINESS_TYPES,
BUSINESS_TYPE_KEYS,
PC_CAT_MORE,
PC_CAT_PRIMARY,
catOptionsFor,
isLocalLife,
type BusinessType,
@@ -31,7 +33,6 @@ type ProductPayload = {
title?: string;
brand?: string;
category?: string;
target_audience?: string;
description?: string;
specs?: Record<string, unknown>;
selling_points?: Array<{ title: string; detail: string; sort_order: number }>;
@@ -441,7 +442,6 @@ export function ProductCreateUploadPage({ onCreate, onBack }: { onCreate: (paylo
const [title, setTitle] = useState("");
const [category, setCategory] = useState("");
const [price, setPrice] = useState("");
const [audience, setAudience] = useState("");
const [points, setPoints] = useState<string[]>([]);
const [pointDraft, setPointDraft] = useState("");
@@ -469,7 +469,6 @@ export function ProductCreateUploadPage({ onCreate, onBack }: { onCreate: (paylo
onCreate({
title,
category,
target_audience: audience,
specs: { source: "product-create-upload", ...(price ? { price } : {}) },
selling_points: points.map((item, index) => ({ title: item, detail: item, sort_order: index }))
});
@@ -529,7 +528,12 @@ export function ProductCreateUploadPage({ onCreate, onBack }: { onCreate: (paylo
<label className="field-label"><span className="req">*</span></label>
<select className="select" value={category} onChange={(event) => setCategory(event.target.value)} required>
<option value=""> </option>
{PC_CAT_OPTIONS.map((option) => <option key={option}>{option}</option>)}
<optgroup label="常用品类">
{PC_CAT_PRIMARY.map((option) => <option key={option}>{option}</option>)}
</optgroup>
<optgroup label="更多品类">
{PC_CAT_MORE.map((option) => <option key={option}>{option}</option>)}
</optgroup>
</select>
</div>
<div className="field field-last">
@@ -540,15 +544,15 @@ export function ProductCreateUploadPage({ onCreate, onBack }: { onCreate: (paylo
</div>
{/* 卖点 & 人群 */}
{/* 卖点 */}
<div className="form-card form-card-wide">
<div className="card-h">
<h3> & </h3>
<h3></h3>
<span className="opt-tag"> · </span>
</div>
<div className="ai-tip">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round"><path d="M12 3l1.8 4.2L18 9l-4.2 1.8L12 15l-1.8-4.2L6 9l4.2-1.8L12 3z" /></svg>
<span>, AI (<strong> / </strong> ) ,,</span>
<span>, AI (<strong> / </strong> ) </span>
</div>
<div className="field">
@@ -570,10 +574,6 @@ export function ProductCreateUploadPage({ onCreate, onBack }: { onCreate: (paylo
</li>
</ul>
</div>
<div className="field field-last">
<label className="field-label"></label>
<input className="input" value={audience} onChange={(event) => setAudience(event.target.value)} placeholder="例: 22-32 岁女性、敏感肌、办公室通勤" />
</div>
</div>
{/* 底部操作 */}
@@ -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<BusinessType>(realType);
const [target, setTarget] = useState(realTarget);
const [points, setPoints] = useState<string[]>(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
</select>
</div>
</div>
<div className="row" data-field="target">
<div className="k"></div>
<div className="v">
<span className="v-static">{realTarget}</span>
<input className="v-edit v-input" type="text" value={target} onChange={(event) => setTarget(event.target.value)} />
</div>
</div>
<div className="row" data-field="bullets">
<div className="k"></div>
<div className="v">
+1 -1
View File
@@ -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) {
+81 -18
View File
@@ -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<File[]>([]);
const [savedImages, setSavedImages] = useState<Array<{ asset_id: string; url: string }>>([]);
const [sourceProductId, setSourceProductId] = useState("");
@@ -126,7 +139,7 @@ export function QuickCreatePage({
const [unavailableMessage, setUnavailableMessage] = useState("");
const [pollEpoch, setPollEpoch] = useState(0);
const [history, setHistory] = useState<QuickCreateJob[]>([]);
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({
<input className="quick-name-input" autoComplete="off" value={name} onChange={(event) => setName(event.target.value)} placeholder="例如:净颜精华、轻醒咖啡" disabled={isGenerating} />
</label>
<div className="quick-field">
<span className="quick-field-label"><span></span><small> · </small></span>
<div className={`quick-cat-grid${catMoreOpen ? " is-more-open" : ""}`} role="listbox" aria-label="商品品类">
{PC_CAT_PRIMARY.map((option) => (
<button
type="button"
className={`chip quick-cat-chip${category === option ? " active" : ""}`}
key={option}
disabled={isGenerating}
aria-selected={category === option}
onClick={() => { setCategory(option); setCatMoreOpen(false); }}
>
{option}
</button>
))}
<button
type="button"
className={`chip quick-cat-chip quick-cat-more${catMoreOpen ? " is-open" : ""}`}
disabled={isGenerating}
aria-expanded={catMoreOpen}
onClick={() => setCatMoreOpen((open) => !open)}
>
<span>{PC_CAT_MORE_LABEL}</span>
<ChevronRight />
</button>
{catMoreOpen ? PC_CAT_MORE.map((option) => (
<button
type="button"
className={`chip quick-cat-chip${category === option ? " active" : ""}`}
key={option}
disabled={isGenerating}
aria-selected={category === option}
onClick={() => setCategory(option)}
>
{option}
</button>
)) : null}
</div>
</div>
<div className="quick-field">
<span className="quick-field-label"><span></span><small> · 9</small></span>
<div className={`quick-upload${imageCount ? " has-images" : ""}`}>
@@ -613,7 +677,7 @@ export function QuickCreatePage({
<X /><span>{cancelling ? "正在取消…" : "取消生成"}</span>
</button>
) : (
<button type="button" className="quick-generate-button" onClick={() => void startGeneration()} disabled={!canRetry}>
<button type="button" className="quick-generate-button" onClick={() => void startGeneration()} disabled={!canStart}>
<WandSparkles /><span>{`立即生成视频 · 消耗 ${estimatedPoints} 积分`}</span>
</button>
)}
@@ -646,24 +710,23 @@ export function QuickCreatePage({
<div className="quick-state quick-state-complete">
<div className={`quick-video-result-grid${videoClips.length === 1 ? " is-single" : ""}`}>
{videoClips.map((clip, index) => (
{videoClips.map((clip, index) => {
const clipUrl = clip.video_url || result?.video_url || "";
return (
<button
key={clip.id}
type="button"
className="quick-video-result-card"
onClick={() => playClip(clip.video_url || result?.video_url, clip.poster_url || result?.poster_url || posterFallback, `${index + 1}场视频`)}
onClick={() => playClip(clipUrl, `${index + 1}场视频`)}
>
<span className="quick-video-result-thumb">
{clip.poster_url || result?.poster_url || posterFallback ? (
<img src={clip.poster_url || result?.poster_url || posterFallback} alt={`${index + 1}场视频首帧`} />
) : clip.video_url ? (
<video src={clip.video_url} muted playsInline preload="metadata" />
) : null}
{clipUrl ? <video src={clipUrl} muted playsInline preload="metadata" /> : null}
<span className="quick-video-play" aria-hidden="true"><Play /></span>
</span>
<span className="quick-video-result-meta"><strong>{index + 1}</strong><small>{clip.duration_seconds || 15}</small></span>
</button>
))}
);
})}
</div>
<div className="quick-result-head">
<div>
@@ -676,7 +739,7 @@ export function QuickCreatePage({
<button type="button" className="secondary-action" onClick={resetResult}><RefreshCw /></button>
{videoClips.length > 1 ? (
mergedUrl ? (
<button type="button" className="secondary-action" onClick={() => playClip(mergedUrl, result?.poster_url || posterFallback, "完整视频")}>
<button type="button" className="secondary-action" onClick={() => playClip(mergedUrl, "完整视频")}>
<Play />
</button>
) : (
@@ -743,7 +806,7 @@ export function QuickCreatePage({
{history.length ? (
<div className="quick-history-list">
{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({
<button
type="button"
className="quick-history-thumb"
onClick={() => playClip(videoUrl, poster, historyTitle(item))}
onClick={() => playClip(videoUrl, historyTitle(item))}
aria-label={canPlay ? `播放${historyTitle(item)}` : historyTitle(item)}
disabled={!canPlay}
>
{poster ? <img src={poster} alt="" /> : <span className="quick-history-thumb-empty">{canPlay ? null : <Play />}</span>}
{cover ? <img src={cover} alt="" /> : <span className="quick-history-thumb-empty">{canPlay ? null : <Play />}</span>}
{canPlay ? <span className="quick-history-play" aria-hidden="true"><Play /></span> : null}
<small>{formatClock(duration)}</small>
</button>
@@ -798,7 +861,7 @@ export function QuickCreatePage({
<strong>{playing.title}</strong>
<button type="button" onClick={() => setPlaying(null)} aria-label="关闭播放"><X /></button>
</div>
<video src={playing.url} poster={playing.poster || undefined} controls autoPlay playsInline controlsList="nodownload" />
<video src={playing.url} controls autoPlay playsInline controlsList="nodownload" />
</div>
</div>
) : null}
+6 -1
View File
@@ -30,6 +30,7 @@ export type Page =
| "freeCreate"
| "quickCreate"
| "videoRemix"
| "videoReplace"
| "imageOptimize"
| "modelPhoto"
| "modelPhotoDemoA"
@@ -109,6 +110,7 @@ export const routeLabels: Record<Page, string> = {
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":
+768
View File
@@ -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<FreeVideoTask> | null) {
const name = productNameFromPrompt(task?.prompt);
return name ? `${name}视频复刻` : "视频复刻预览";
}
function remixSourceLabel(task?: Partial<FreeVideoTask> | 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<File | null>(null);
const [videoRef, setVideoRef] = useState<FreeVideoRef | null>(null);
const [videoUploading, setVideoUploading] = useState(false);
const [videoMeta, setVideoMeta] = useState({ duration: 0, width: 0, height: 0 });
const [source, setSource] = useState<ProductSource>("");
const [selectedProduct, setSelectedProduct] = useState<Product | null>(null);
const [tempFiles, setTempFiles] = useState<File[]>([]);
const [tempPreviews, setTempPreviews] = useState<string[]>([]);
const [libraryOpen, setLibraryOpen] = useState(false);
const [pendingProductId, setPendingProductId] = useState("");
const [jobId, setJobId] = useState(readJobId);
const [job, setJob] = useState<FreeVideoTask | null>(null);
const [history, setHistory] = useState<FreeVideoTask[]>([]);
const [submitting, setSubmitting] = useState(false);
const [billingRates, setBillingRates] = useState<BillingRates>(DEFAULT_BILLING_RATES);
const [playing, setPlaying] = useState<{ url: string; title: string } | null>(null);
const videoInputRef = useRef<HTMLInputElement>(null);
const tempInputRef = useRef<HTMLInputElement>(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 (
<div className="vrep-page">
<div className="vrep-inner">
<section className="video-tool-page replace-page">
<header className="page-header">
<div className="image-title-row">
<button type="button" className="image-back-button" aria-label="返回上一入口页面" onClick={onBack}>
<ArrowLeft />
</button>
<div className="page-heading">
<h1></h1>
</div>
</div>
</header>
<div className="video-flow-grid">
<section className="video-flow-panel replace-flow-panel">
<h2></h2>
<div className="video-flow-step">
<div className="video-flow-step-head">
<strong>1. </strong>
<span>MP4 / MOV · 60 </span>
</div>
<label className={`video-upload-field${videoFile ? " has-file" : ""}`}>
<input
ref={videoInputRef}
type="file"
accept={VIDEO_ACCEPT}
hidden
onChange={(event) => {
void pickVideo(event.target.files?.[0] || null);
event.target.value = "";
}}
/>
<span>
<FileVideo2 />
<strong>{videoFile ? videoFile.name : "点击上传参考视频"}</strong>
<small>{videoFile ? "视频已就绪,将自动识别原商品区域" : "系统将自动识别需要替换的商品区域"}</small>
</span>
</label>
</div>
<div className="video-flow-step">
<div className="video-flow-step-head">
<strong>2. </strong>
<span></span>
</div>
<div className="product-replace-options">
<button
type="button"
className={`replace-product-method${source === "library" ? " active" : ""}${libraryPreview ? " has-product-preview" : ""}`}
onClick={() => {
setPendingProductId(selectedProduct?.id || "");
setLibraryOpen(true);
}}
>
{libraryPreview ? <img className="replace-product-method-background" src={libraryPreview} alt="" aria-hidden="true" /> : <img className="replace-product-method-background" alt="" aria-hidden="true" />}
<span className="replace-product-method-icon"><LibraryBig /></span>
<span className="replace-product-method-copy">
<strong></strong>
<small>{source === "library" && selectedProduct ? `已选择 · ${selectedProduct.title}` : "选择已创建的商品"}</small>
</span>
<ChevronRight />
</button>
<label
className={`replace-product-method replace-temporary-method${source === "temporary" ? " active" : ""}${tempFiles.length ? " has-images" : ""}`}
>
<img className="replace-product-method-background" alt="" aria-hidden="true" />
<span className="replace-product-method-icon"><Upload /></span>
<span className="replace-product-method-copy">
<strong></strong>
<small>{tempFiles.length ? `已上传 ${tempFiles.length} 张商品图` : "仅用于本次任务 · 最多 9 张"}</small>
</span>
<ImagePlus />
<span className="replace-temporary-preview">
<span className="replace-temporary-grid" aria-label="临时上传的商品图片">
{cells.map((file, index) => (
<span className={`replace-temporary-cell${file ? "" : " empty"}`} key={`temp-${index}`}>
{file ? (
<>
<img src={tempPreviews[index]} alt={file.name} />
<button
type="button"
className="replace-temporary-remove"
aria-label={`删除第${index + 1}张临时商品图片`}
onClick={(event) => {
event.preventDefault();
event.stopPropagation();
setTempFiles((current) => current.filter((_, itemIndex) => itemIndex !== index));
if (job && !isInFlight(job.status)) setJob(null);
onNotify("info", "已删除临时商品图片");
}}
>
×
</button>
</>
) : null}
</span>
))}
</span>
<span className="replace-temporary-more">
<span><ImagePlus /></span>
<strong></strong>
<span className="replace-temporary-count"> {tempFiles.length} / 9</span>
<button
type="button"
className="replace-temporary-clear"
onClick={(event) => {
event.preventDefault();
event.stopPropagation();
setTempFiles([]);
if (source === "temporary") setSource("");
if (job && !isInFlight(job.status)) setJob(null);
onNotify("info", "已清空临时商品图片");
}}
>
</button>
</span>
</span>
<input
ref={tempInputRef}
type="file"
accept="image/jpeg,image/png,image/webp"
multiple
hidden
onChange={(event) => {
addTempImages(event.target.files);
event.target.value = "";
}}
/>
</label>
</div>
</div>
<div className="video-flow-actions replace-generate-action">
<button
type="button"
className="primary-action"
disabled={!videoFile || !productReady || generating}
onClick={() => void startGeneration()}
>
<Replace />
<span>{generateLabel}</span>
</button>
</div>
</section>
<aside className={panelClass} aria-live="polite">
<div className="video-result-placeholder">
<div>
<div className="replace-placeholder-visual">
<span><Replace /></span>
</div>
<strong></strong>
</div>
</div>
<div className="replace-generating-state" role="status" aria-live="polite">
<div className="replace-generating-content">
<div className="replace-generating-visual">
<span className="replace-generating-frame"><Clapperboard /></span>
<span className="replace-generating-product"><Package /></span>
</div>
<strong></strong>
<span></span>
<div className="replace-generating-bar" aria-hidden="true"><span /></div>
</div>
</div>
<div className="video-analysis-result">
<h2></h2>
<div className="replace-preview">
{job?.video_url ? <video src={job.video_url} poster={job.thumbnail_url || undefined} muted playsInline /> : null}
<div className="replace-preview-copy">
<strong>{productName ? `${productName}视频复刻预览` : remixTitle(job)}</strong>
<span>{outputDuration} · {ratioCopy(job?.aspect_ratio || aspectRatio)} · </span>
</div>
</div>
<div className="video-flow-actions replace-result-actions">
<button type="button" className="secondary-action" onClick={() => void startGeneration()}>
<RefreshCw />
</button>
<button type="button" className="primary-action" onClick={() => downloadVideo(job?.video_url || "", productName || "视频复刻")}>
<Download />
</button>
</div>
</div>
</aside>
</div>
<section className="replace-history-section" aria-labelledby="replaceHistoryTitle">
<div className="replace-history-head">
<h2 id="replaceHistoryTitle"></h2>
<span>{history.length} </span>
</div>
{history.length === 0 ? (
<div className="replace-history-empty"></div>
) : (
<div className="replace-history-list">
{history.map((item) => (
<article className="replace-history-card" key={item.id}>
<div
className="replace-history-cover"
onClick={() => {
if (!item.video_url) return;
setPlaying({ url: item.video_url, title: remixTitle(item) });
}}
>
{item.thumbnail_url ? <img src={item.thumbnail_url} alt={`${remixTitle(item)}封面`} /> : null}
<span>{formatClock(item.duration)}</span>
</div>
<div className="replace-history-copy">
<span></span>
<h3>{remixTitle(item)}</h3>
<p> {item.duration} · {remixSourceLabel(item)} · {item.aspect_ratio} · {item.resolution}</p>
</div>
<button type="button" className="replace-history-open" onClick={() => openHistory(item)}>
<ArrowUpRight />
</button>
</article>
))}
</div>
)}
</section>
</section>
</div>
<div className={`asset-library-layer${libraryOpen ? " open" : ""}`} aria-hidden={!libraryOpen}>
<button type="button" className="asset-library-scrim" aria-label="关闭资产库" onClick={() => setLibraryOpen(false)} />
<aside className="asset-library-drawer" role="dialog" aria-modal="true" aria-labelledby="assetLibraryTitle">
<header className="asset-library-head">
<div>
<h2 id="assetLibraryTitle"></h2>
<p></p>
</div>
<button type="button" className="product-drawer-close" aria-label="关闭" onClick={() => setLibraryOpen(false)}>
<X />
</button>
</header>
<div className="asset-library-grid">
{products.length === 0 ? (
<div className="asset-library-empty"></div>
) : products.map((product) => (
<button
type="button"
className={`asset-library-choice${pendingProductId === product.id ? " selected" : ""}`}
key={product.id}
onClick={() => setPendingProductId(product.id)}
>
<span className="asset-choice-check"><Check /></span>
{productCover(product) ? <img src={productCover(product)} alt={product.title} /> : <img alt={product.title} />}
<span>
<strong>{product.title}</strong>
<small>{product.category || "未分类"} · {productImageCount(product)} </small>
</span>
</button>
))}
</div>
<footer className="asset-library-footer">
<button type="button" className="secondary-action" onClick={() => setLibraryOpen(false)}></button>
<button type="button" className="primary-action" onClick={confirmLibraryProduct}>
<Check />
<span>使</span>
</button>
</footer>
</aside>
</div>
<MediaLightbox
open={Boolean(playing?.url)}
src={playing?.url || ""}
kind="video"
name={playing?.title}
close={() => setPlaying(null)}
/>
</div>
);
}
+46 -20
View File
@@ -106,36 +106,59 @@ 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<string, Combo> = {
: { 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 = "商品信息不足,先给一组最通用的";
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) {
@@ -144,14 +167,17 @@ export function recommendSetup(input: { category?: string; targetAudience?: stri
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,
};
}
File diff suppressed because it is too large Load Diff