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

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, "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 对齐)。 # 设定卡人物 key → 中文(与前端 WIZ_PERSONA_LABEL / 模板 coerce_persona 对齐)。
PERSONA_LABELS: dict[str, str] = { PERSONA_LABELS: dict[str, str] = {
"urban": "都市白领女性", "urban": "都市白领女性",
@@ -177,6 +222,68 @@ def coerce_combo(fmt: str | None, structure: str | None) -> tuple[str, str]:
return fmt, structure 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: def narration_limit(duration: int) -> int:
"""这一镜旁白的字数上限:秒数 × 5.2,且不超过硬上限 78。""" """这一镜旁白的字数上限:秒数 × 5.2,且不超过硬上限 78。"""
return max(1, min(NARRATION_CHARS_HARD_CAP, int(duration * NARRATION_CHARS_PER_SECOND))) 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"品牌:{product.brand or '未填写'}\n"
f"{type_line}" f"{type_line}"
f"品类:{product.category or '未填写'}\n" 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"人物口吻:{persona_brief or '按人物设定自己定一个具体身份,不要用万能主播腔'}\n"
f"商品描述:{desc}\n" f"商品描述:{desc}\n"
f"规格:{specs_block}\n" f"规格:{specs_block}\n"
@@ -572,6 +682,7 @@ def build_agent_messages(
suggested = plan_segment_durations(total, fmt) suggested = plan_segment_durations(total, fmt)
shot_n = len(suggested) shot_n = len(suggested)
stage_plan = structure_stage_plan(structure, shot_n)
extra = (user_prompt or "").strip() extra = (user_prompt or "").strip()
if target_index is not None and base_draft: if target_index is not None and base_draft:
existing_n = len(base_draft.get("segments") or []) existing_n = len(base_draft.get("segments") or [])
@@ -610,24 +721,10 @@ def build_agent_messages(
f"【写法硬约束】有人声的镜口播/对白 {speech_floor}{speech_cap} 字,允许个别镜纯画面;" f"【写法硬约束】有人声的镜口播/对白 {speech_floor}{speech_cap} 字,允许个别镜纯画面;"
f"visual 至少 {VISUAL_CHARS_MIN} 字。\n" f"visual 至少 {VISUAL_CHARS_MIN} 字。\n"
) )
structure_line = "" structure_line = STRUCTURE_CONTRACTS[structure]
if structure == "scene": stage_line = "【镜序验收】" + "".join(
structure_line = ( f"{index + 1} 镜:{stage}" for index, stage in enumerate(stage_plan)
"【场景种草骨架】按镜走完:场景建立(商品先不出现)→商品被使用着入场→场景因它更好/软CTA。" ) + "。镜数不够时只可合并相邻步骤,顺序不得颠倒,也不得插入其他结构的主线。\n"
"每镜写出具体时刻+地点+光/声音/物件。口播时每镜仍要说满字数,留白靠停顿不是靠少写字。\n"
)
elif structure == "promo":
structure_line = (
"【促销抢购合规】只可使用商品资料中明确给出的价格、优惠、库存、赠品和活动时间;"
"没有明确数据时,绝不写原价、最低价、限时、限量、领券或赠品。"
"结尾只给一个清楚的购买入口和操作动作,不制造虚假的紧迫感。\n"
)
elif structure == "knowledge":
structure_line = (
"【知识分享骨架】先提出一个与商品相关的选购/使用误区或判断方法,再用实物与步骤证明,"
"最后把商品作为符合该判断的具体实例。只能讲商品资料可支持的事实,"
"禁止编造专业身份、数据、标准或功效承诺。\n"
)
combo_line = ( combo_line = (
f"严格按已加载的「{PRESENTATION_FORMATS[fmt]} × {VIDEO_STRUCTURES[structure]}」套路写," f"严格按已加载的「{PRESENTATION_FORMATS[fmt]} × {VIDEO_STRUCTURES[structure]}」套路写,"
f"不要串成别的结构的套话。\n" f"不要串成别的结构的套话。\n"
@@ -646,6 +743,7 @@ def build_agent_messages(
f"{writing_line}" f"{writing_line}"
f"{beats_line}" f"{beats_line}"
f"{structure_line}" f"{structure_line}"
f"{stage_line}"
f"{combo_line}" f"{combo_line}"
f"{authenticity_line}" f"{authenticity_line}"
f"【商品信息】\n{_product_context(project, selling_point_ids, persona)}" f"【商品信息】\n{_product_context(project, selling_point_ids, persona)}"
@@ -685,7 +783,7 @@ def build_agent_messages(
"写 visual 时把拆解稿里的景别/机位/运镜/人物动作/表情/音效/背景音乐/字幕/备注" "写 visual 时把拆解稿里的景别/机位/运镜/人物动作/表情/音效/背景音乐/字幕/备注"
"折进【声音】和【画面内容】的秒级分镜,不要压成一句画面摘要。" "折进【声音】和【画面内容】的秒级分镜,不要压成一句画面摘要。"
"原稿写「无 / 不可见 / 听不清」的栏不要编造。" "原稿写「无 / 不可见 / 听不清」的栏不要编造。"
"钩子、痛点、卖点必须能对上这份商品" "钩子、已选结构的核心步骤和商品事实必须能对上。"
"每镜旁白要能撑满指定时长;画面必须按秒拆分镜,够导演在 15 秒里切 3–5 刀。\n" "每镜旁白要能撑满指定时长;画面必须按秒拆分镜,够导演在 15 秒里切 3–5 刀。\n"
f"{head}" f"{head}"
f"{extra_block}\n" f"{extra_block}\n"
@@ -695,7 +793,7 @@ def build_agent_messages(
user = ( user = (
"【任务】全自动(模式①):仅凭上面的商品事实与前置条件,按指定的表现形式与视频结构套路" "【任务】全自动(模式①):仅凭上面的商品事实与前置条件,按指定的表现形式与视频结构套路"
"自动定镜/选 tone/造 entity/填结构骨架。" "自动定镜/选 tone/造 entity/填结构骨架。"
"不要另起一个空主题;钩子、痛点、卖点必须能对上这份商品,而不是品类套话。" "不要另起一个空主题;钩子、已选结构的核心步骤和商品事实必须能对上这份商品,而不是品类套话。"
"每镜旁白要能撑满 15 秒;画面必须按秒拆分镜,够导演在 15 秒里切 3–5 刀。\n" "每镜旁白要能撑满 15 秒;画面必须按秒拆分镜,够导演在 15 秒里切 3–5 刀。\n"
f"{head}" f"{head}"
f"{extra_block}\n" f"{extra_block}\n"
@@ -793,6 +891,20 @@ def plan_segment_durations(total_duration: int, presentation_format: str | None
return [SEGMENT_DURATION_MAX] * count 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]: def plan_roles(count: int) -> list[str]:
"""镜数 → role 序列。通用规则:首钩子、次痛点、末 CTA,中间全是卖点。""" """镜数 → role 序列。通用规则:首钩子、次痛点、末 CTA,中间全是卖点。"""
if count <= 1: if count <= 1:
+18 -3
View File
@@ -894,6 +894,20 @@ class VideoSubmissionStateUnknown(ProviderOutcomeUnknownError):
"""视频提交可能已到达供应商但未拿到可靠任务 ID;禁止自动重提。""" """视频提交可能已到达供应商但未拿到可靠任务 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( def execute_routed_video_submit(
*, *,
task: AITask, task: AITask,
@@ -913,6 +927,7 @@ def execute_routed_video_submit(
"""异步视频只路由“提交”阶段;拿到 Provider 任务 ID 后固定该实际模型轮询。""" """异步视频只路由“提交”阶段;拿到 Provider 任务 ID 后固定该实际模型轮询。"""
from apps.billing.pricing import quote_video_estimate from apps.billing.pricing import quote_video_estimate
prompt = enforce_no_embedded_captions(prompt)
references = list(reference_images or []) references = list(reference_images or [])
routed_content_items = list(content_items) if content_items is not None else None routed_content_items = list(content_items) if content_items is not None else None
pricing_refs = list(pricing_references or []) 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 ""), 补充=(("\n" + extra_prompt.strip()) if extra_prompt else ""),
) )
cleaned = "\n".join(line for line in rendered.split("\n") if line.strip()) 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: 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}"), 脚本=(script_text or f"{video_segment.sort_order + 1}"),
时长=video_segment.target_duration_seconds, 时长=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: 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 ""), 补充=(("\n" + extra_prompt.strip()) if extra_prompt else ""),
) )
cleaned = "\n".join(line for line in rendered.split("\n") if line.strip()) 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: def _is_transient_error(exc: Exception) -> bool:
@@ -13,6 +13,7 @@ from apps.ai.script_agent import (
DEFAULT_TOTAL_DURATION, DEFAULT_TOTAL_DURATION,
PRESENTATION_FORMATS, PRESENTATION_FORMATS,
SEGMENT_DURATION_MAX, SEGMENT_DURATION_MAX,
STRUCTURE_CONTRACTS,
TOTAL_DURATION_MAX, TOTAL_DURATION_MAX,
TOTAL_DURATION_MIN, TOTAL_DURATION_MIN,
VIDEO_STRUCTURES, VIDEO_STRUCTURES,
@@ -35,6 +36,8 @@ from apps.ai.script_agent import (
parse_visual_beats, parse_visual_beats,
plan_roles, plan_roles,
plan_segment_durations, plan_segment_durations,
recommend_script_setup,
structure_stage_plan,
) )
ALL_TOTALS = [15, 30, 45, 60] ALL_TOTALS = [15, 30, 45, 60]
@@ -81,6 +84,16 @@ class SegmentPlanningTests(SimpleTestCase):
self.assertEqual(plan[0], "钩子") self.assertEqual(plan[0], "钩子")
self.assertEqual(plan[-1], "CTA") 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): class SegmentDurationFittingTests(SimpleTestCase):
def test_uneven_durations_are_forced_to_15(self): def test_uneven_durations_are_forced_to_15(self):
@@ -265,7 +278,6 @@ class PromptAssemblyTests(SimpleTestCase):
product.brand = "AirBrand" product.brand = "AirBrand"
product.business_type = "ecommerce" product.business_type = "ecommerce"
product.category = "美妆" product.category = "美妆"
product.target_audience = "油皮"
product.description = "夜间修护" product.description = "夜间修护"
product.specs = {"price": "99", "source": "product-create-upload"} product.specs = {"price": "99", "source": "product-create-upload"}
product.selling_points.all.return_value = _QS([sp]) product.selling_points.all.return_value = _QS([sp])
@@ -290,6 +302,7 @@ class PromptAssemblyTests(SimpleTestCase):
self.assertNotIn("一句话主题扩写", user) self.assertNotIn("一句话主题扩写", user)
self.assertIn("【补充要求】节奏紧凑,适合短视频投放", user) self.assertIn("【补充要求】节奏紧凑,适合短视频投放", user)
self.assertIn("控油12小时", user) self.assertIn("控油12小时", user)
self.assertNotIn("目标人群:", user)
self.assertIn("都市白领女性", user) self.assertIn("都市白领女性", user)
self.assertIn("价格:99", user) self.assertIn("价格:99", user)
self.assertIn("每镜必须 15 秒", user) self.assertIn("每镜必须 15 秒", user)
@@ -301,6 +314,38 @@ class PromptAssemblyTests(SimpleTestCase):
self.assertIn("禁止使用「唯一缺点", user) self.assertIn("禁止使用「唯一缺点", user)
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): def test_video_digest_prompt_maps_camera_and_sound(self):
digest = ( digest = (
"片名:《雨夜归家》\n视频类型:剧情短片\n" "片名:《雨夜归家》\n视频类型:剧情短片\n"
@@ -338,7 +383,7 @@ class PromptAssemblyTests(SimpleTestCase):
persona="urban", persona="urban",
) )
user = messages[1]["content"] user = messages[1]["content"]
self.assertIn("场景种草骨架", user) self.assertIn("场景种草·结构合同】", user)
self.assertIn("口播每镜必须说满", user) self.assertIn("口播每镜必须说满", user)
self.assertIn("一共 3 镜", user) self.assertIn("一共 3 镜", user)
self.assertIn("秒级分镜", user) self.assertIn("秒级分镜", user)
@@ -550,3 +595,45 @@ class SkillLoadingTests(SimpleTestCase):
self.assertIn("每镜固定 15 秒", text) self.assertIn("每镜固定 15 秒", text)
self.assertIn("商品原词", text) self.assertIn("商品原词", text)
self.assertNotIn("单镜 415 秒,允许不等长", 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.accounts.models import Team, TeamMember, User
from apps.ai.models import AITask, ModelConfig, ModelProvider 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.assets.models import Asset
from apps.billing.models import CreditAccount, CreditLedger from apps.billing.models import CreditAccount, CreditLedger
from apps.products.models import Product 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.RESERVE), 1)
self.assertEqual(self.ledger_count(task, CreditLedger.Type.CHARGE), 0) self.assertEqual(self.ledger_count(task, CreditLedger.Type.CHARGE), 0)
self.assertEqual(self.ledger_count(task, CreditLedger.Type.RELEASE), 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): 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) 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 = ( _GENERIC_WEARABLE_HINTS = (
"围巾", "披肩", "领带", "领结", "", "腰带", "皮带", "手套", "护膝", "护腕", "服饰", "服装", "穿戴", "衣帽", "鞋服", "围巾", "披肩", "领带", "领结", "", "腰带", "皮带", "手套", "护膝", "护腕", "服饰", "服装", "穿戴", "衣帽", "鞋服",
) )
_BROAD_WEARABLE_CATEGORIES = ("服饰内衣", "服饰", "服装", "女装", "男装", "配饰") _BROAD_WEARABLE_CATEGORIES = ("服饰内衣", "服饰鞋包", "服饰", "服装", "女装", "男装", "配饰")
_USER_RELATION_RULES: tuple[tuple[ProductKind, tuple[str, ...]], ...] = ( _USER_RELATION_RULES: tuple[tuple[ProductKind, tuple[str, ...]], ...] = (
(ProductKind.HANDHELD, ("手持", "拿着", "拿在手", "握住")), (ProductKind.HANDHELD, ("手持", "拿着", "拿在手", "握住")),
(ProductKind.WEARABLE_GENERIC, ("穿上", "穿着", "换上", "佩戴", "戴上", "戴着")), (ProductKind.WEARABLE_GENERIC, ("穿上", "穿着", "换上", "佩戴", "戴上", "戴着")),
+5
View File
@@ -12,6 +12,11 @@ class Product(TeamOwnedModel):
ECOMMERCE = "ecommerce", "电商" ECOMMERCE = "ecommerce", "电商"
LOCAL_LIFE = "local_life", "本地生活" LOCAL_LIFE = "local_life", "本地生活"
ECOMMERCE_CATEGORIES = (
"美妆个护", "食品饮料", "服饰鞋包", "家居日用", "数码家电", "母婴玩具", "运动健康",
"珠宝配饰", "汽车用品", "宠物用品", "图书文教", "五金农资", "其他商品",
)
title = models.CharField(max_length=255) title = models.CharField(max_length=255)
brand = models.CharField(max_length=128, blank=True) brand = models.CharField(max_length=128, blank=True)
# 类目(电商/本地生活)≠品类(美妆/食品)。类目驱动节点屏蔽与后续 skill 挂载。 # 类目(电商/本地生活)≠品类(美妆/食品)。类目驱动节点屏蔽与后续 skill 挂载。
+6 -5
View File
@@ -423,11 +423,12 @@ class QuickCreateJobSerializer(serializers.ModelSerializer):
if version is not None and is_playable_video(version.asset): if version is not None and is_playable_video(version.asset):
video_url = _asset_preview_url(version.asset) video_url = _asset_preview_url(version.asset)
break break
first_shot = next( product = getattr(project, "product", None)
(shot for shot in project.storyboard_shots.all() if shot.adopted_version_id), cover = getattr(product, "cover_asset", None) if product is not None else None
None, poster_url = _asset_preview_url(cover) if cover else ""
) if not poster_url and product is not None:
poster_url = _asset_preview_url(first_shot.adopted_version.asset) if first_shot else "" 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 = 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 {} script_meta = (script.metadata or {}) if script is not None else {}
format_label = {"oral": "口播展示", "drama": "短剧演绎", "vlog": "Vlog种草"}.get( format_label = {"oral": "口播展示", "drama": "短剧演绎", "vlog": "Vlog种草"}.get(
@@ -15,7 +15,13 @@ from django.db import connections, transaction
from django.utils import timezone from django.utils import timezone
from apps.ai.models import AITask, ModelConfig 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 ( from apps.ai.services import (
collect_video_review_blockers, collect_video_review_blockers,
create_export_job, create_export_job,
@@ -364,6 +370,17 @@ def _consume_script_agent(job: QuickCreateJob) -> None:
if model_config is None: if model_config is None:
raise ValueError(f"{QUICK_SCRIPT_MODEL_NAME} is not configured") 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 = "" error_detail = ""
stream = stream_script_agent( stream = stream_script_agent(
project=project, project=project,
@@ -371,15 +388,16 @@ def _consume_script_agent(job: QuickCreateJob) -> None:
model_config=model_config, model_config=model_config,
mode="auto", mode="auto",
user_prompt=( 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"], aspect_ratio=settings["aspect_ratio"],
total_duration=settings["total_duration"], total_duration=settings["total_duration"],
presentation_format="oral", presentation_format=fmt,
video_structure="pain", video_structure=structure,
entry_source="ai", entry_source="ai",
persona="reviewer", persona=persona,
) )
# 豆包思考模型会连续吐几百帧 reasoning。原先每帧 refresh_from_db,远程 MySQL # 豆包思考模型会连续吐几百帧 reasoning。原先每帧 refresh_from_db,远程 MySQL
# 一抖动就会把生成器关掉,模型调用被误记成 stream aborted (client disconnected)。 # 一抖动就会把生成器关掉,模型调用被误记成 stream aborted (client disconnected)。
@@ -76,6 +76,7 @@ class QuickCreateApiTests(TestCase):
"aspect_ratio": "9:16", "aspect_ratio": "9:16",
"resolution": "720p", "resolution": "720p",
"total_duration": "30", "total_duration": "30",
"category": "食品饮料",
}, },
format="multipart", format="multipart",
) )
@@ -83,10 +84,14 @@ class QuickCreateApiTests(TestCase):
self.assertEqual(response.status_code, 202) self.assertEqual(response.status_code, 202)
product = Product.objects.get(title="轻醒咖啡") product = Product.objects.get(title="轻醒咖啡")
self.assertEqual(product.images.count(), 2) 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) self.assertEqual(product.cover_asset_id, product.images.order_by("sort_order").first().asset_id)
project = Project.objects.get(product=product) project = Project.objects.get(product=product)
self.assertTrue(project.metadata["quick_create"]) self.assertTrue(project.metadata["quick_create"])
self.assertEqual(project.metadata["wizard"]["total_duration"], 30) 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"]["resolution"], "720p")
self.assertEqual(project.metadata["wizard"]["video_model_config_id"], str(video_model_id)) self.assertEqual(project.metadata["wizard"]["video_model_config_id"], str(video_model_id))
self.assertEqual(project.stages.count(), 5) 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")}, {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.services.quick_create.get_quick_script_model", return_value=object())
@patch("apps.projects.views.get_default_model", return_value=object()) @patch("apps.projects.views.get_default_model", return_value=object())
@patch("apps.projects.tasks.advance_quick_create_task.apply_async") @patch("apps.projects.tasks.advance_quick_create_task.apply_async")
@@ -129,6 +158,7 @@ class QuickCreateApiTests(TestCase):
{ {
"name": "复用商品图", "name": "复用商品图",
"source_product_id": str(source.id), "source_product_id": str(source.id),
"category": "美妆个护",
"aspect_ratio": "9:16", "aspect_ratio": "9:16",
"resolution": "720p", "resolution": "720p",
"total_duration": "15", "total_duration": "15",
@@ -140,6 +170,7 @@ class QuickCreateApiTests(TestCase):
store_asset.assert_not_called() store_asset.assert_not_called()
product = Product.objects.get(title="复用商品图") product = Product.objects.get(title="复用商品图")
self.assertEqual(product.images.count(), 1) self.assertEqual(product.images.count(), 1)
self.assertEqual(product.category, "美妆个护")
self.assertEqual(product.images.first().asset_id, source_asset.id) self.assertEqual(product.images.first().asset_id, source_asset.id)
self.assertEqual(response.data["product_images"][0]["asset_id"], str(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) self.assertEqual(self.job.status, QuickCreateJob.Status.RUNNING)
enqueue.assert_called_once() 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.get_quick_script_model")
@patch("apps.projects.services.quick_create.stream_script_agent") @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): 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, SEGMENT_DURATION_MIN,
combo_keys, combo_keys,
coerce_total_duration, coerce_total_duration,
recommend_script_setup,
stream_script_agent, stream_script_agent,
) )
from apps.ai.services import ( from apps.ai.services import (
@@ -607,6 +608,11 @@ class ProjectViewSet(TeamScopedViewSetMixin, ModelViewSet):
return Response({"detail": "请至少上传一张商品图片"}, status=status.HTTP_400_BAD_REQUEST) return Response({"detail": "请至少上传一张商品图片"}, status=status.HTTP_400_BAD_REQUEST)
if len(reused_assets) + len(uploads) > 9: if len(reused_assets) + len(uploads) > 9:
return Response({"detail": "商品图片最多上传9张"}, status=status.HTTP_400_BAD_REQUEST) 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(): with transaction.atomic():
assets = list(reused_assets) assets = list(reused_assets)
@@ -628,6 +634,7 @@ class ProjectViewSet(TeamScopedViewSetMixin, ModelViewSet):
created_by=request.user, created_by=request.user,
title=name, title=name,
business_type=Product.BusinessType.ECOMMERCE, business_type=Product.BusinessType.ECOMMERCE,
category=category,
cover_asset=assets[0], cover_asset=assets[0],
) )
ProductImage.objects.bulk_create( ProductImage.objects.bulk_create(
@@ -641,6 +648,7 @@ class ProjectViewSet(TeamScopedViewSetMixin, ModelViewSet):
for index, asset in enumerate(assets) for index, asset in enumerate(assets)
] ]
) )
rec = recommend_script_setup(category, name)
project = Project.objects.create( project = Project.objects.create(
team=team, team=team,
created_by=request.user, created_by=request.user,
@@ -657,9 +665,9 @@ class ProjectViewSet(TeamScopedViewSetMixin, ModelViewSet):
"video_model_config_id": str(video_model.id), "video_model_config_id": str(video_model.id),
"video_model_name": video_model.name, "video_model_name": video_model.name,
"video_model_label": video_model.display_name, "video_model_label": video_model.display_name,
"presentation_format": "oral", "presentation_format": rec["format"],
"video_structure": "pain", "video_structure": rec["structure"],
"persona": "reviewer", "persona": rec["persona"],
}, },
}, },
) )
@@ -697,9 +705,10 @@ class ProjectViewSet(TeamScopedViewSetMixin, ModelViewSet):
def _quick_job_queryset(self): def _quick_job_queryset(self):
return ( return (
QuickCreateJob.objects.select_related("project__product", "project__timeline") QuickCreateJob.objects.select_related("project__product", "project__product__cover_asset", "project__timeline")
.prefetch_related( .prefetch_related(
"project__product__images__asset__files", "project__product__images__asset__files",
"project__product__cover_asset__files",
"project__script_versions", "project__script_versions",
"project__base_asset_groups", "project__base_asset_groups",
"project__storyboard_shots__adopted_version__asset__files", "project__storyboard_shots__adopted_version__asset__files",
+12
View File
@@ -32,6 +32,7 @@ import {
FreeCreatePage, FreeCreatePage,
QuickCreatePage, QuickCreatePage,
VideoRemixPage, VideoRemixPage,
VideoReplacePage,
ImageWorkbenchPage, ImageWorkbenchPage,
LibraryPage, LibraryPage,
MessagesPage, MessagesPage,
@@ -1105,6 +1106,17 @@ export function App() {
); );
case "videoRemix": 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} />; 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": 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 })} />; 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": case "modelPhoto":
+3 -2
View File
@@ -301,8 +301,9 @@ export const api = {
revokeInvitation(id: string) { revokeInvitation(id: string) {
return request<Invitation>(`/api/auth/team/invitations/${id}/revoke/`, { method: "POST" }); return request<Invitation>(`/api/auth/team/invitations/${id}/revoke/`, { method: "POST" });
}, },
products() { products(pageSize?: number) {
return request<Paginated<Product>>("/api/products/"); const query = pageSize ? `?page_size=${pageSize}` : "";
return request<Paginated<Product>>(`/api/products/${query}`);
}, },
product(id: string) { product(id: string) {
return request<Product>(`/api/products/${id}/`); 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"/>', 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"/>', 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"/>', 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"/>', 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"/>' 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: "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: "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-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: "library", group: "导航", label: "成品库", sub: "素材、人物、场景、成片统一管理", page: "library", icon: "folder", key: "A" },
{ id: "team", group: "导航", label: "团队", sub: "成员、权限、额度、协作记录", page: "team", icon: "users" }, { id: "team", group: "导航", label: "团队", sub: "成员、权限、额度、协作记录", page: "team", icon: "users" },
{ id: "account", group: "导航", label: "账单库", sub: "余额、充值、账单流水", page: "account", icon: "creditCard" }, { id: "account", group: "导航", label: "账单库", sub: "余额、充值、账单流水", page: "account", icon: "creditCard" },
@@ -272,7 +273,7 @@ export function topModuleForPage(page: Page): TopModule | null {
|| page === "modelPhotoDemoB" || page === "modelPhotoDemoB"
|| page === "platformCover" || page === "platformCover"
) return "image"; ) 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; return null;
} }
@@ -404,6 +405,7 @@ const PAGE_TO_NAV: Partial<Record<Page, Page>> = {
platformCover: "assetFactory", platformCover: "assetFactory",
freeCreate: "freeCreate", freeCreate: "freeCreate",
videoRemix: "projects", videoRemix: "projects",
videoReplace: "projects",
library: "library", library: "library",
team: "team", team: "team",
account: "account", account: "account",
@@ -1,14 +1,17 @@
import { useEffect, useRef, useState } from "react"; import { useEffect, useRef, useState } from "react";
import type { ChangeEvent, DragEvent } from "react"; import type { ChangeEvent, DragEvent } from "react";
import { createPortal } from "react-dom"; 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 { api } from "../api";
import { useBodyScrollLock, ConfirmModal } from "./overlays"; import { useBodyScrollLock, ConfirmModal } from "./overlays";
import type { Product } from "../types"; import type { Product } from "../types";
import { import {
BUSINESS_TYPES, BUSINESS_TYPES,
BUSINESS_TYPE_KEYS, BUSINESS_TYPE_KEYS,
PC_CAT_MORE,
PC_CAT_MORE_LABEL,
PC_CAT_OPTIONS, PC_CAT_OPTIONS,
PC_CAT_PRIMARY,
catOptionsFor, catOptionsFor,
type BusinessType, type BusinessType,
} from "../product-business"; } from "../product-business";
@@ -20,7 +23,6 @@ export type ProductCreatePayload = {
title: string; title: string;
business_type?: BusinessType; business_type?: BusinessType;
category: string; category: string;
target_audience?: string;
selling_points?: Array<{ title: string; detail: string; sort_order: number }>; selling_points?: Array<{ title: string; detail: string; sort_order: number }>;
cover_asset?: string; cover_asset?: string;
images?: Array<{ asset: string; sort_order: number; is_primary?: boolean }>; 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 [title, setTitle] = useState("");
const [businessType, setBusinessType] = useState<BusinessType>("ecommerce"); const [businessType, setBusinessType] = useState<BusinessType>("ecommerce");
const [category, setCategory] = useState(""); const [category, setCategory] = useState("");
const [target, setTarget] = useState("");
const [bullets, setBullets] = useState<string[]>([""]); const [bullets, setBullets] = useState<string[]>([""]);
const [images, setImages] = useState<PfImage[]>([]); const [images, setImages] = useState<PfImage[]>([]);
const [dragOver, setDragOver] = useState(false); const [dragOver, setDragOver] = useState(false);
@@ -60,6 +61,7 @@ export function ProductCreateDrawer({ open, close, onCreate, onCreated, catOptio
const [saving, setSaving] = useState(false); const [saving, setSaving] = useState(false);
const [galleryOpen, setGalleryOpen] = useState(false); const [galleryOpen, setGalleryOpen] = useState(false);
const [openSelect, setOpenSelect] = useState<"" | "type" | "cat">(""); const [openSelect, setOpenSelect] = useState<"" | "type" | "cat">("");
const [catMoreOpen, setCatMoreOpen] = useState(false);
const [toast, setToast] = useState<{ title: string; sub: string } | null>(null); const [toast, setToast] = useState<{ title: string; sub: string } | null>(null);
const [confirmExit, setConfirmExit] = useState(false); const [confirmExit, setConfirmExit] = useState(false);
const imgInputRef = useRef<HTMLInputElement | null>(null); const imgInputRef = useRef<HTMLInputElement | null>(null);
@@ -84,7 +86,6 @@ export function ProductCreateDrawer({ open, close, onCreate, onCreated, catOptio
setTitle(""); setTitle("");
setBusinessType("ecommerce"); setBusinessType("ecommerce");
setCategory(""); setCategory("");
setTarget("");
setBullets([""]); setBullets([""]);
setImages((list) => { list.forEach((item) => URL.revokeObjectURL(item.url)); return []; }); setImages((list) => { list.forEach((item) => URL.revokeObjectURL(item.url)); return []; });
setDragOver(false); setDragOver(false);
@@ -92,6 +93,7 @@ export function ProductCreateDrawer({ open, close, onCreate, onCreated, catOptio
setCategoryError(false); setCategoryError(false);
setGalleryOpen(false); setGalleryOpen(false);
setOpenSelect(""); setOpenSelect("");
setCatMoreOpen(false);
} }
useEffect(() => { useEffect(() => {
@@ -114,7 +116,7 @@ export function ProductCreateDrawer({ open, close, onCreate, onCreated, catOptio
}, [openSelect]); }, [openSelect]);
function isDirty() { 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) { function changeBusinessType(next: BusinessType) {
@@ -227,7 +229,6 @@ export function ProductCreateDrawer({ open, close, onCreate, onCreated, catOptio
title: name, title: name,
business_type: businessType, business_type: businessType,
category: category || activeCatOptions[0], category: category || activeCatOptions[0],
target_audience: target.trim() || undefined,
selling_points: finalBullets.map((item, index) => ({ title: item, detail: item, sort_order: index })), selling_points: finalBullets.map((item, index) => ({ title: item, detail: item, sort_order: index })),
...(images[0]?.assetId ...(images[0]?.assetId
? { ? {
@@ -299,31 +300,57 @@ export function ProductCreateDrawer({ open, close, onCreate, onCreated, catOptio
<div className="pcd-field"> <div className="pcd-field">
<span className="pcd-label"><span> <span className="pcd-req">*</span></span><small></small></span> <span className="pcd-label"><span> <span className="pcd-req">*</span></span><small></small></span>
<div className={`pcd-select${openSelect === "cat" ? " open" : ""}`}> <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={() => setOpenSelect((c) => (c === "cat" ? "" : "cat"))}> <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> <span className={category ? "" : "is-placeholder"}>{category || "请选择商品品类"}</span>
<ChevronDown /> <ChevronDown />
</button> </button>
<div className="pcd-select-menu" role="listbox"> <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(""); }}> <button type="button" className={`pcd-select-option${category === option ? " selected" : ""}`} key={option} onClick={() => { setCategory(option); setCategoryError(false); setOpenSelect(""); }}>
{option} {option}
</button> </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>
</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"> <section className="pcd-block" aria-labelledby="productImageTitle">
<div className="pcd-block-head"> <div className="pcd-block-head">
<div> <div>
+1
View File
@@ -15,6 +15,7 @@ import "./settings-page.css";
import "./ai-tools-page.css"; import "./ai-tools-page.css";
import "./free-create-page.css"; import "./free-create-page.css";
import "./video-remix-page.css"; import "./video-remix-page.css";
import "./video-replace-page.css";
import "./product-create-page.css"; import "./product-create-page.css";
import "./project-wizard-page.css"; import "./project-wizard-page.css";
import "./quick-create-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 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 混在一个下拉里 */ /** 本地生活品类 · 团购/到店核销走这条,不跟电商 SKU 混在一个下拉里 */
export const PC_LOCAL_CAT_OPTIONS = ["餐饮美食", "休闲娱乐", "丽人美发", "酒店民宿", "生活服务", "运动健身", "亲子玩乐", "团购套餐"]; export const PC_LOCAL_CAT_OPTIONS = ["餐饮美食", "休闲娱乐", "丽人美发", "酒店民宿", "生活服务", "运动健身", "亲子玩乐", "团购套餐", "更多品类"];
export function isLocalLife(product?: { business_type?: string } | null): boolean { export function isLocalLife(product?: { business_type?: string } | null): boolean {
return product?.business_type === "local_life"; return product?.business_type === "local_life";
+23
View File
@@ -418,6 +418,8 @@
display: grid; display: grid;
gap: 3px; gap: 3px;
padding: 6px; padding: 6px;
max-height: min(360px, 60vh);
overflow-y: auto;
border: 1px solid rgba(34, 42, 54, 0.10); border: 1px solid rgba(34, 42, 54, 0.10);
border-radius: 11px; border-radius: 11px;
background: #fff; background: #fff;
@@ -433,6 +435,10 @@
pointer-events: auto; pointer-events: auto;
transform: translateY(0); transform: translateY(0);
} }
.pcd-select.is-more-open .pcd-select-menu {
max-height: min(640px, 78vh);
padding-bottom: 10px;
}
.pcd-select-option { .pcd-select-option {
min-height: 38px; min-height: 38px;
padding: 0 10px; padding: 0 10px;
@@ -447,6 +453,23 @@
} }
.pcd-select-option:hover { background: #e8f2ff; } .pcd-select-option:hover { background: #e8f2ff; }
.pcd-select-option.selected { color: var(--klein); background: #f3f7ff; font-weight: 600; } .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 { margin-top: 8px; padding-top: 22px; border-top: 1px solid rgba(34, 42, 54, 0.08); }
.pcd-block-head { .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-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-field { display: grid; gap: 9px; margin-top: 20px; }
.quick-create-page .quick-form-copy + .quick-field { margin-top: 17px; } .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 { 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-field-label small { color: var(--quick-muted); font-size: 11px; font-weight: 500; }
.quick-create-page .quick-name-input { height: 50px; padding: 0 15px; border: 1px solid rgba(34,42,54,.13); border-radius: 12px; outline: none; background: rgba(248,249,252,.88); transition: border-color 180ms ease, box-shadow 180ms ease, background 180ms ease; } .quick-create-page .quick-name-input { 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-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-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 { 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 { 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-play svg { width: 42px; height: 42px; fill: currentColor; stroke: currentColor; stroke-width: 1.2; filter: drop-shadow(0 2px 8px rgba(0,0,0,.28)); }
.quick-create-page .quick-video-result-meta { display: flex; align-items: center; justify-content: space-between; gap: 8px; padding: 8px 3px 0; } .quick-create-page .quick-video-result-meta { 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: "复用成熟视频的镜头表达与节奏", hint: "复用成熟视频的镜头表达与节奏",
cards: [ cards: [
{ title: "提炼提示词", desc: "上传参考视频,提炼可编辑提示词", tone: "subtle", page: "videoRemix", icon: "scan" }, { 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 { FreeCreatePage } from "./free-create";
export { QuickCreatePage } from "./quick-create"; export { QuickCreatePage } from "./quick-create";
export { VideoRemixPage } from "./video-remix"; export { VideoRemixPage } from "./video-remix";
export { VideoReplacePage } from "./video-replace";
export { SettingsPage } from "./settings"; export { SettingsPage } from "./settings";
+19 -13
View File
@@ -1450,10 +1450,9 @@ export function PipelinePage(props: {
const recommended = useMemo( const recommended = useMemo(
() => recommendSetup({ () => recommendSetup({
category: setupProduct?.category, category: setupProduct?.category,
targetAudience: setupProduct?.target_audience,
title: setupProduct?.title, title: setupProduct?.title,
}), }),
[setupProduct?.category, setupProduct?.target_audience, setupProduct?.title] [setupProduct?.category, setupProduct?.title]
); );
const wizard = project.metadata?.wizard; const wizard = project.metadata?.wizard;
const [setupOpen, setSetupOpen] = useState(false); const [setupOpen, setSetupOpen] = useState(false);
@@ -1464,7 +1463,9 @@ export function PipelinePage(props: {
const [setupStructure, setSetupStructure] = useState<VideoStructure>( const [setupStructure, setSetupStructure] = useState<VideoStructure>(
coerceVideoStructure(wizard?.video_structure, recommended.structure) 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>(() => { const [setupDuration, setSetupDuration] = useState<number>(() => {
if (typeof wizard?.total_duration === "number") return clampDuration(wizard.total_duration); if (typeof wizard?.total_duration === "number") return clampDuration(wizard.total_duration);
// 兼容一期向导存的字符串档位("0-30" → 30) // 兼容一期向导存的字符串档位("0-30" → 30)
@@ -1476,12 +1477,16 @@ export function PipelinePage(props: {
const setupTouched = useRef(false); const setupTouched = useRef(false);
const wizardHasCombo = Boolean(wizard?.presentation_format && wizard?.video_structure); const wizardHasCombo = Boolean(wizard?.presentation_format && wizard?.video_structure);
const wizardHasDuration = typeof wizard?.total_duration === "number" || Boolean(wizard?.duration); const wizardHasDuration = typeof wizard?.total_duration === "number" || Boolean(wizard?.duration);
const wizardHasPersona = Boolean(wizard?.persona);
useEffect(() => { useEffect(() => {
if (setupTouched.current || wizardHasCombo) return; if (setupTouched.current) return;
setSetupFormat(recommended.format); if (!wizardHasCombo) {
setSetupStructure(recommended.structure); setSetupFormat(recommended.format);
if (!wizardHasDuration) setSetupDuration(recommended.duration); setSetupStructure(recommended.structure);
}, [recommended.format, recommended.structure, recommended.duration, wizardHasCombo, wizardHasDuration]); if (!wizardHasDuration) setSetupDuration(recommended.duration);
}
if (!wizardHasPersona) setSetupPersona(recommended.persona);
}, [recommended.format, recommended.structure, recommended.persona, recommended.duration, wizardHasCombo, wizardHasDuration, wizardHasPersona]);
// ── 5.2 换商品重跑 ── 新建向导选的套路模板由后端回填进 metadata.wizard,这里只读不写 // ── 5.2 换商品重跑 ── 新建向导选的套路模板由后端回填进 metadata.wizard,这里只读不写
const templateName = typeof wizard?.template_name === "string" ? wizard.template_name : ""; const templateName = typeof wizard?.template_name === "string" ? wizard.template_name : "";
@@ -1875,7 +1880,7 @@ export function PipelinePage(props: {
`${base}\n以上是我拆解一条参考视频得到的分镜稿。请照搬它的镜头顺序、每镜时长比例、景别、机位、运镜、人物动作和声音层次,` `${base}\n以上是我拆解一条参考视频得到的分镜稿。请照搬它的镜头顺序、每镜时长比例、景别、机位、运镜、人物动作和声音层次,`
+ `把人物、商品、品牌、台词全部换成我自己的商品;参考视频里出现的商品、品牌、人物一律不要保留。` + `把人物、商品、品牌、台词全部换成我自己的商品;参考视频里出现的商品、品牌、人物一律不要保留。`
+ `写 visual 时把每镜的景别、机位、运镜、动作、表情、音效、背景音乐、字幕、备注折进导演说明书,不要压成一句画面摘要。` + `写 visual 时把每镜的景别、机位、运镜、动作、表情、音效、背景音乐、字幕、备注折进导演说明书,不要压成一句画面摘要。`
+ `原稿里标注「无 / 不可见 / 听不清 / 存疑」的地方不要编造,由你按我的商品补全。目标人群:${personaLabel}`, + `原稿里标注「无 / 不可见 / 听不清 / 存疑」的地方不要编造,由你按我的商品补全。人物设定:${personaLabel}`,
`上传视频提炼 · ${combo}`, `上传视频提炼 · ${combo}`,
"video", "video",
); );
@@ -1891,7 +1896,7 @@ export function PipelinePage(props: {
// 上传脚本:先识别原稿结构,再补齐钩子 / 卖点证明 / 转化,而不是照抄一遍 // 上传脚本:先识别原稿结构,再补齐钩子 / 卖点证明 / 转化,而不是照抄一遍
await runScriptGeneration( await runScriptGeneration(
`${base}\n以上是我已有的脚本。请先识别它的结构与叙述顺序,尽量保留原意与原有表达,再据此整理成镜头脚本;` `${base}\n以上是我已有的脚本。请先识别它的结构与叙述顺序,尽量保留原意与原有表达,再据此整理成镜头脚本;`
+ `其中开头钩子、卖点的证明方式、结尾转化引导如有缺失或偏弱,请补齐补强。目标人群:${personaLabel}`, + `其中开头钩子、卖点的证明方式、结尾转化引导如有缺失或偏弱,请补齐补强。人物设定:${personaLabel}`,
`上传脚本 · ${combo}`, `上传脚本 · ${combo}`,
"manual", "manual",
); );
@@ -3326,11 +3331,11 @@ export function PipelinePage(props: {
<div className="setup-rec">{STRUCTURE_HINT[setupStructure]}</div> <div className="setup-rec">{STRUCTURE_HINT[setupStructure]}</div>
<label className="setup-field"> <label className="setup-field">
<span className="sf-k"></span> <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>)} {SETUP_PERSONA_KEYS.map((k) => <option key={k} value={k}>{WIZ_PERSONA_LABEL[k]}</option>)}
</select> </select>
</label> </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 秒 */} {/* 时长:15/30/45/60;每镜固定 15 秒 */}
<label className="setup-field"> <label className="setup-field">
<span className="sf-k"></span> <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" onClick={() => setSetupOpen(false)}> </button>
<button type="button" className="btn btn-ghost btn-sm" title={recommended.reason} onClick={() => { <button type="button" className="btn btn-ghost btn-sm" title={recommended.reason} onClick={() => {
// 重新推荐:回到按商品信息算出的那一组(不是随机换,随机换等于没推荐) // 重新推荐:回到按商品品类算出的那一组(不是随机换,随机换等于没推荐)
setupTouched.current = false; setupTouched.current = false;
setSetupFormat(recommended.format); setSetupFormat(recommended.format);
setSetupStructure(recommended.structure); setSetupStructure(recommended.structure);
setSetupPersona(recommended.persona);
setSetupDuration(recommended.duration); setSetupDuration(recommended.duration);
}}></button> }}></button>
<button type="button" className="btn btn-primary btn-sm" disabled={loading || scriptFileBusy || videoDigestBusy || ((setupSource === "manual" || setupSource === "video") && !chatText.trim())} onClick={() => void runScriptWithSetup()}></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 { ArrowLeft, Check, ChevronDown, Grid2X2, List, PackagePlus, PackageX, Search, Settings2, Trash2, X } from "lucide-react";
import { ConfirmModal, MediaLightbox, SuccessModal } from "../components/overlays"; import { ConfirmModal, MediaLightbox, SuccessModal } from "../components/overlays";
import { ReviewBadge, type ReviewStatus } from "../components/review-badge"; 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 { import {
BUSINESS_TYPES, BUSINESS_TYPES,
BUSINESS_TYPE_KEYS, BUSINESS_TYPE_KEYS,
PC_CAT_MORE,
PC_CAT_PRIMARY,
catOptionsFor, catOptionsFor,
isLocalLife, isLocalLife,
type BusinessType, type BusinessType,
@@ -31,7 +33,6 @@ type ProductPayload = {
title?: string; title?: string;
brand?: string; brand?: string;
category?: string; category?: string;
target_audience?: string;
description?: string; description?: string;
specs?: Record<string, unknown>; specs?: Record<string, unknown>;
selling_points?: Array<{ title: string; detail: string; sort_order: number }>; 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 [title, setTitle] = useState("");
const [category, setCategory] = useState(""); const [category, setCategory] = useState("");
const [price, setPrice] = useState(""); const [price, setPrice] = useState("");
const [audience, setAudience] = useState("");
const [points, setPoints] = useState<string[]>([]); const [points, setPoints] = useState<string[]>([]);
const [pointDraft, setPointDraft] = useState(""); const [pointDraft, setPointDraft] = useState("");
@@ -469,7 +469,6 @@ export function ProductCreateUploadPage({ onCreate, onBack }: { onCreate: (paylo
onCreate({ onCreate({
title, title,
category, category,
target_audience: audience,
specs: { source: "product-create-upload", ...(price ? { price } : {}) }, specs: { source: "product-create-upload", ...(price ? { price } : {}) },
selling_points: points.map((item, index) => ({ title: item, detail: item, sort_order: index })) 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> <label className="field-label"><span className="req">*</span></label>
<select className="select" value={category} onChange={(event) => setCategory(event.target.value)} required> <select className="select" value={category} onChange={(event) => setCategory(event.target.value)} required>
<option value=""> </option> <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> </select>
</div> </div>
<div className="field field-last"> <div className="field field-last">
@@ -540,15 +544,15 @@ export function ProductCreateUploadPage({ onCreate, onBack }: { onCreate: (paylo
</div> </div>
{/* 卖点 & 人群 */} {/* 卖点 */}
<div className="form-card form-card-wide"> <div className="form-card form-card-wide">
<div className="card-h"> <div className="card-h">
<h3> & </h3> <h3></h3>
<span className="opt-tag"> · </span> <span className="opt-tag"> · </span>
</div> </div>
<div className="ai-tip"> <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> <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>
<div className="field"> <div className="field">
@@ -570,10 +574,6 @@ export function ProductCreateUploadPage({ onCreate, onBack }: { onCreate: (paylo
</li> </li>
</ul> </ul>
</div> </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> </div>
{/* 底部操作 */} {/* 底部操作 */}
@@ -598,7 +598,7 @@ export function ProductCreateUploadPage({ onCreate, onBack }: { onCreate: (paylo
} }
// 商品详情页 · 从 public/exact/product-detail.html 忠实转写。 // 商品详情页 · 从 public/exact/product-detail.html 忠实转写。
// 名称 / 品类 / 目标人群 / 卖点 来自 product;商品图网格 / AI 素材卡 / 视频项目卡 // 名称 / 品类 / 卖点 来自 product;商品图网格 / AI 素材卡 / 视频项目卡
// 已接入真实数据(product.images + 团队 assets + 该商品 projects),保持设计稿像素布局。 // 已接入真实数据(product.images + 团队 assets + 该商品 projects),保持设计稿像素布局。
// 素材审核状态(只读,反映团队审核结果,非用户可点切换) // 素材审核状态(只读,反映团队审核结果,非用户可点切换)
type PdAssetStatus = "pass" | "fail" | "pending"; type PdAssetStatus = "pass" | "fail" | "pending";
@@ -913,13 +913,11 @@ export function ProductDetailPage({ product, projects, initialTab = "assets", na
const realCat = product.category || ""; const realCat = product.category || "";
const realType = (product.business_type === "local_life" ? "local_life" : "ecommerce") as BusinessType; const realType = (product.business_type === "local_life" ? "local_life" : "ecommerce") as BusinessType;
const isLocal = realType === "local_life"; const isLocal = realType === "local_life";
const realTarget = product.target_audience || "";
const realBullets = product.selling_points.map((point) => point.title); const realBullets = product.selling_points.map((point) => point.title);
const [name, setName] = useState(realName); const [name, setName] = useState(realName);
const [cat, setCat] = useState(realCat); const [cat, setCat] = useState(realCat);
const [bizType, setBizType] = useState<BusinessType>(realType); const [bizType, setBizType] = useState<BusinessType>(realType);
const [target, setTarget] = useState(realTarget);
const [points, setPoints] = useState<string[]>(realBullets); const [points, setPoints] = useState<string[]>(realBullets);
// R104:显式「添加卖点」按钮追加一条空的可编辑卖点行(行内回车同样追加), // R104:显式「添加卖点」按钮追加一条空的可编辑卖点行(行内回车同样追加),
@@ -939,7 +937,6 @@ export function ProductDetailPage({ product, projects, initialTab = "assets", na
title: name, title: name,
business_type: bizType, business_type: bizType,
category: cat, category: cat,
target_audience: target,
// 卖点整体替换(后端 present 即覆盖):发 {title,detail,sort_order} 不带 id; // 卖点整体替换(后端 present 即覆盖):发 {title,detail,sort_order} 不带 id;
// 过滤空行(点了「添加卖点」还没填的行),否则后端 title 非空校验会退 400 // 过滤空行(点了「添加卖点」还没填的行),否则后端 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"] 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); setName(realName);
setCat(realCat); setCat(realCat);
setBizType(realType); setBizType(realType);
setTarget(realTarget);
setPoints(realBullets); setPoints(realBullets);
setEditing(false); setEditing(false);
setActionsLockH(null); setActionsLockH(null);
@@ -1085,13 +1081,6 @@ export function ProductDetailPage({ product, projects, initialTab = "assets", na
</select> </select>
</div> </div>
</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="row" data-field="bullets">
<div className="k"></div> <div className="k"></div>
<div className="v"> <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 }> = [ 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: "freeCreate", image: "/assets/yz/video-free.jpg" },
{ title: "提炼提示词", desc: "从参考视频中提炼可编辑的视频生成提示词。", page: "videoRemix", image: "/assets/yz/video-remix.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) { function isQuickCreateProject(project: Project) {
+81 -18
View File
@@ -4,6 +4,7 @@ import {
ArrowLeft, ArrowLeft,
Boxes, Boxes,
Clapperboard, Clapperboard,
ChevronRight,
ImagePlus, ImagePlus,
LayoutPanelTop, LayoutPanelTop,
RefreshCw, RefreshCw,
@@ -30,6 +31,12 @@ import {
type BillingRates, type BillingRates,
} from "../components/free-create/constants"; } from "../components/free-create/constants";
import type { NavigateFn } from "./route-config"; 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 = [ const PROGRESS_STEPS = [
{ label: "脚本", icon: ScrollText }, { label: "脚本", icon: ScrollText },
@@ -85,6 +92,10 @@ function historyBadge(item: QuickCreateJob) {
return "未完成"; return "未完成";
} }
function historyCover(item: QuickCreateJob) {
return item.product_images?.[0]?.url || "";
}
function historyVideoUrl(item: QuickCreateJob) { function historyVideoUrl(item: QuickCreateJob) {
return item.result?.video_url || item.result?.video_segments?.[0]?.video_url || ""; return item.result?.video_url || item.result?.video_segments?.[0]?.video_url || "";
} }
@@ -113,6 +124,8 @@ export function QuickCreatePage({
modelConfigs: ModelConfig[]; modelConfigs: ModelConfig[];
}) { }) {
const [name, setName] = useState(""); const [name, setName] = useState("");
const [category, setCategory] = useState("");
const [catMoreOpen, setCatMoreOpen] = useState(false);
const [images, setImages] = useState<File[]>([]); const [images, setImages] = useState<File[]>([]);
const [savedImages, setSavedImages] = useState<Array<{ asset_id: string; url: string }>>([]); const [savedImages, setSavedImages] = useState<Array<{ asset_id: string; url: string }>>([]);
const [sourceProductId, setSourceProductId] = useState(""); const [sourceProductId, setSourceProductId] = useState("");
@@ -126,7 +139,7 @@ export function QuickCreatePage({
const [unavailableMessage, setUnavailableMessage] = useState(""); const [unavailableMessage, setUnavailableMessage] = useState("");
const [pollEpoch, setPollEpoch] = useState(0); const [pollEpoch, setPollEpoch] = useState(0);
const [history, setHistory] = useState<QuickCreateJob[]>([]); 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( const videoConfigs = useMemo(
() => modelConfigs.filter((config) => config.capability === "video" && config.status === "active"), () => modelConfigs.filter((config) => config.capability === "video" && config.status === "active"),
[modelConfigs], [modelConfigs],
@@ -182,6 +195,10 @@ export function QuickCreatePage({
productPrefillDoneRef.current = true; productPrefillDoneRef.current = true;
setName((current) => current || product.title || ""); setName((current) => current || product.title || "");
setSourceProductId((current) => current || product.id); 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) => { setSavedImages((current) => {
if (current.length) return current; if (current.length) return current;
return (product.images || []) return (product.images || [])
@@ -299,12 +316,12 @@ export function QuickCreatePage({
return () => window.removeEventListener("keydown", onKey); return () => window.removeEventListener("keydown", onKey);
}, [playing]); }, [playing]);
function playClip(url?: string, poster?: string, title?: string) { function playClip(url?: string, title?: string) {
if (!url) { if (!url) {
onNotify?.("info", "视频还不能播放,请稍后再试"); onNotify?.("info", "视频还不能播放,请稍后再试");
return; return;
} }
setPlaying({ url, poster: poster || "", title: title || "预览视频" }); setPlaying({ url, title: title || "预览视频" });
} }
function selectImages(files: FileList | null) { function selectImages(files: FileList | null) {
@@ -372,6 +389,10 @@ export function QuickCreatePage({
onNotify?.("info", "请先填写商品名称并上传商品图片"); onNotify?.("info", "请先填写商品名称并上传商品图片");
return; return;
} }
if (!category) {
onNotify?.("info", "请选择商品品类,脚本会按品类来写");
return;
}
setSubmitting(true); setSubmitting(true);
setJob(null); setJob(null);
setServiceUnavailable(false); setServiceUnavailable(false);
@@ -381,6 +402,7 @@ export function QuickCreatePage({
try { try {
const form = new FormData(); const form = new FormData();
form.append("name", name.trim()); form.append("name", name.trim());
form.append("category", category);
form.append("aspect_ratio", aspectRatio); form.append("aspect_ratio", aspectRatio);
form.append("resolution", resolution); form.append("resolution", resolution);
form.append("total_duration", String(totalDuration)); form.append("total_duration", String(totalDuration));
@@ -433,6 +455,8 @@ export function QuickCreatePage({
function clearDraft() { function clearDraft() {
setName(""); setName("");
setCategory("");
setCatMoreOpen(false);
setImages([]); setImages([]);
setSavedImages([]); setSavedImages([]);
setSourceProductId(""); setSourceProductId("");
@@ -501,6 +525,7 @@ export function QuickCreatePage({
const reviewBlocked = isReviewFailure(job?.error_message); const reviewBlocked = isReviewFailure(job?.error_message);
const displayUrls = [...savedImages.map((image) => image.url), ...imagePreviews]; const displayUrls = [...savedImages.map((image) => image.url), ...imagePreviews];
const imageCount = savedImages.length + images.length; const imageCount = savedImages.length + images.length;
const canStart = Boolean(name.trim() && category && imageCount && selectedVideoModel) && !reviewBlocked;
const canRetry = Boolean(name.trim() && 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 activePhase = submitting ? 0 : Math.max(0, Math.min(4, job?.phase_index ?? 0));
const result = job?.result; const result = job?.result;
@@ -511,7 +536,6 @@ export function QuickCreatePage({
: []; : [];
const clipUrls = new Set(videoClips.map((clip) => clip.video_url).filter(Boolean)); 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 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 sceneCount = Math.max(1, Math.round(totalDuration / 15));
const videoEstimate = estimateCost( const videoEstimate = estimateCost(
selectedVideoModel, 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} /> <input className="quick-name-input" autoComplete="off" value={name} onChange={(event) => setName(event.target.value)} placeholder="例如:净颜精华、轻醒咖啡" disabled={isGenerating} />
</label> </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"> <div className="quick-field">
<span className="quick-field-label"><span></span><small> · 9</small></span> <span className="quick-field-label"><span></span><small> · 9</small></span>
<div className={`quick-upload${imageCount ? " has-images" : ""}`}> <div className={`quick-upload${imageCount ? " has-images" : ""}`}>
@@ -613,7 +677,7 @@ export function QuickCreatePage({
<X /><span>{cancelling ? "正在取消…" : "取消生成"}</span> <X /><span>{cancelling ? "正在取消…" : "取消生成"}</span>
</button> </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> <WandSparkles /><span>{`立即生成视频 · 消耗 ${estimatedPoints} 积分`}</span>
</button> </button>
)} )}
@@ -646,24 +710,23 @@ export function QuickCreatePage({
<div className="quick-state quick-state-complete"> <div className="quick-state quick-state-complete">
<div className={`quick-video-result-grid${videoClips.length === 1 ? " is-single" : ""}`}> <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 <button
key={clip.id} key={clip.id}
type="button" type="button"
className="quick-video-result-card" 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"> <span className="quick-video-result-thumb">
{clip.poster_url || result?.poster_url || posterFallback ? ( {clipUrl ? <video src={clipUrl} muted playsInline preload="metadata" /> : null}
<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}
<span className="quick-video-play" aria-hidden="true"><Play /></span> <span className="quick-video-play" aria-hidden="true"><Play /></span>
</span> </span>
<span className="quick-video-result-meta"><strong>{index + 1}</strong><small>{clip.duration_seconds || 15}</small></span> <span className="quick-video-result-meta"><strong>{index + 1}</strong><small>{clip.duration_seconds || 15}</small></span>
</button> </button>
))} );
})}
</div> </div>
<div className="quick-result-head"> <div className="quick-result-head">
<div> <div>
@@ -676,7 +739,7 @@ export function QuickCreatePage({
<button type="button" className="secondary-action" onClick={resetResult}><RefreshCw /></button> <button type="button" className="secondary-action" onClick={resetResult}><RefreshCw /></button>
{videoClips.length > 1 ? ( {videoClips.length > 1 ? (
mergedUrl ? ( mergedUrl ? (
<button type="button" className="secondary-action" onClick={() => playClip(mergedUrl, result?.poster_url || posterFallback, "完整视频")}> <button type="button" className="secondary-action" onClick={() => playClip(mergedUrl, "完整视频")}>
<Play /> <Play />
</button> </button>
) : ( ) : (
@@ -743,7 +806,7 @@ export function QuickCreatePage({
{history.length ? ( {history.length ? (
<div className="quick-history-list"> <div className="quick-history-list">
{history.map((item) => { {history.map((item) => {
const poster = item.result?.poster_url || ""; const cover = historyCover(item);
const videoUrl = historyVideoUrl(item); const videoUrl = historyVideoUrl(item);
const duration = item.result?.duration_seconds || item.settings?.total_duration || 15; 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)); const scenes = item.result?.video_segments?.length || Math.max(1, Math.round(duration / 15));
@@ -754,11 +817,11 @@ export function QuickCreatePage({
<button <button
type="button" type="button"
className="quick-history-thumb" className="quick-history-thumb"
onClick={() => playClip(videoUrl, poster, historyTitle(item))} onClick={() => playClip(videoUrl, historyTitle(item))}
aria-label={canPlay ? `播放${historyTitle(item)}` : historyTitle(item)} aria-label={canPlay ? `播放${historyTitle(item)}` : historyTitle(item)}
disabled={!canPlay} 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} {canPlay ? <span className="quick-history-play" aria-hidden="true"><Play /></span> : null}
<small>{formatClock(duration)}</small> <small>{formatClock(duration)}</small>
</button> </button>
@@ -798,7 +861,7 @@ export function QuickCreatePage({
<strong>{playing.title}</strong> <strong>{playing.title}</strong>
<button type="button" onClick={() => setPlaying(null)} aria-label="关闭播放"><X /></button> <button type="button" onClick={() => setPlaying(null)} aria-label="关闭播放"><X /></button>
</div> </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>
</div> </div>
) : null} ) : null}
+6 -1
View File
@@ -30,6 +30,7 @@ export type Page =
| "freeCreate" | "freeCreate"
| "quickCreate" | "quickCreate"
| "videoRemix" | "videoRemix"
| "videoReplace"
| "imageOptimize" | "imageOptimize"
| "modelPhoto" | "modelPhoto"
| "modelPhotoDemoA" | "modelPhotoDemoA"
@@ -109,6 +110,7 @@ export const routeLabels: Record<Page, string> = {
freeCreate: "自由创作", freeCreate: "自由创作",
quickCreate: "极速成片", quickCreate: "极速成片",
videoRemix: "提炼提示词", videoRemix: "提炼提示词",
videoReplace: "视频复刻",
imageOptimize: "图片创作", imageOptimize: "图片创作",
modelPhoto: "模特上身图", modelPhoto: "模特上身图",
modelPhotoDemoA: "模特图方案 A", modelPhotoDemoA: "模特图方案 A",
@@ -126,7 +128,7 @@ export function isPage(value: string): value is Page {
export function parentPage(page: Page): Page { export function parentPage(page: Page): Page {
if (["productDetail", "productCreateUpload"].includes(page)) return "products"; 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)) { if (["imageOptimize", "modelPhoto", "modelPhotoDemoA", "modelPhotoDemoB", "platformCover"].includes(page)) {
return "assetFactory"; return "assetFactory";
} }
@@ -173,6 +175,7 @@ export function resolveRoute(): ResolvedRoute {
return { page: "quickCreate", authMode: "login", productId: search.get("product_id") || undefined, hash }; 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-remix") return { page: "videoRemix", authMode: "login", hash };
if (path === "/video-replace") return { page: "videoReplace", authMode: "login", hash };
if (path === "/image-optimize") { if (path === "/image-optimize") {
return { page: "imageOptimize", authMode: "login", productId: search.get("product_id") || undefined, hash }; 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"; return options.productId ? `/quick-create?product_id=${encodeURIComponent(options.productId)}` : "/quick-create";
case "videoRemix": case "videoRemix":
return "/video-remix"; return "/video-remix";
case "videoReplace":
return "/video-replace";
case "imageOptimize": case "imageOptimize":
return options.productId ? `/image-optimize?product_id=${encodeURIComponent(options.productId)}` : "/image-optimize"; return options.productId ? `/image-optimize?product_id=${encodeURIComponent(options.productId)}` : "/image-optimize";
case "modelPhoto": 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>
);
}
+52 -26
View File
@@ -106,52 +106,78 @@ export function durationWarning(structure: VideoStructure, seconds: number): str
return `${VIDEO_STRUCTURES[structure]}低于 ${min} 秒会压不住,建议调长`; 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 }> = [ const CATEGORY_RULES: ReadonlyArray<{ keywords: string[]; combo: Combo }> = [
{ keywords: ["美妆", "护肤", "彩妆", "面膜", "精华", "洗护", "个护"], combo: { format: "oral", structure: "contrast" } }, { keywords: ["美妆", "护肤", "彩妆", "面膜", "精华", "洗护", "个护"], combo: CATEGORY_COMBOS["美妆个护"] },
{ keywords: ["保健", "营养", "膳食", "益生菌", "维生素"], combo: { format: "oral", structure: "review" } }, { keywords: ["保健", "营养", "膳食", "益生菌", "维生素"], combo: { format: "oral", structure: "review", persona: "reviewer" } },
{ keywords: ["数码", "3c", "电子", "电器", "手机", "耳机", "相机", "工具"], combo: { format: "oral", structure: "review" } }, { keywords: ["数码", "3c", "电子", "电器", "手机", "耳机", "相机", "工具", "家电"], combo: CATEGORY_COMBOS["数码家电"] },
{ keywords: ["食品", "零食", "饮料", "咖啡", "茶", "生鲜", "酒"], combo: { format: "vlog", structure: "scene" } }, { keywords: ["食品", "零食", "饮料", "咖啡", "茶", "生鲜", "酒"], combo: CATEGORY_COMBOS["食品饮料"] },
{ keywords: ["服饰", "女装", "男装", "服装", "鞋", "包", "配饰", "内衣"], combo: { format: "vlog", structure: "scene" } }, { keywords: ["服饰", "女装", "男装", "服装", "鞋", "包", "配饰", "内衣"], combo: CATEGORY_COMBOS["服饰鞋包"] },
{ keywords: ["家居", "家纺", "收纳", "厨具", "清洁", "日用"], combo: { format: "drama", structure: "pain" } }, { keywords: ["家居", "家纺", "收纳", "厨具", "清洁", "日用"], combo: CATEGORY_COMBOS["家居日用"] },
{ keywords: ["母婴", "宝宝", "儿童", "宠物"], combo: { format: "drama", structure: "pain" } }, { keywords: ["母婴", "宝宝", "儿童", "玩具"], combo: CATEGORY_COMBOS["母婴玩具"] },
{ keywords: ["户外", "露营", "运动", "健身"], combo: { format: "vlog", structure: "scene" } }, { 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; duration: number;
reason: string; reason: string;
} { } {
const haystack = [input.category, input.title, input.targetAudience] const category = (input.category || "").trim();
const haystack = [category, input.title]
.filter(Boolean) .filter(Boolean)
.join(" ") .join(" ")
.toLowerCase(); .toLowerCase();
let combo = FALLBACK_COMBO; let combo = FALLBACK_COMBO;
let reason = "商品信息不足,先给一组最通用的"; let reason = "商品信息不足,先给一组最通用的";
for (const rule of CATEGORY_RULES) { if (category && CATEGORY_COMBOS[category]) {
const hit = rule.keywords.find((word) => haystack.includes(word.toLowerCase())); combo = CATEGORY_COMBOS[category];
if (hit) { reason = `按「${category}」品类推荐`;
combo = rule.combo; } else {
reason = `按「${hit}」品类推荐`; for (const rule of CATEGORY_RULES) {
break; const hit = rule.keywords.find((word) => haystack.includes(word.toLowerCase()));
if (hit) {
combo = rule.combo;
reason = `按「${hit}」品类推荐`;
break;
}
} }
} }
// 防御:规则表若被改出禁用组合,这里兜住 // 自动推荐只给口播;Vlog / 短剧留给用户自己选。
const structure = isForbidden(combo.format, combo.structure) const format: PresentationFormat = "oral";
? allowedStructures(combo.format)[0] const structure = isForbidden(format, combo.structure)
? allowedStructures(format)[0]
: combo.structure; : combo.structure;
return { return {
format: combo.format, format,
structure, structure,
duration: recommendDuration(combo.format, structure), persona: combo.persona,
duration: recommendDuration(format, structure),
reason, reason,
}; };
} }
File diff suppressed because it is too large Load Diff