优化住流程和添加复刻视频页面
This commit is contained in:
@@ -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:
|
||||||
|
|||||||
@@ -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("单镜 4–15 秒,允许不等长", text)
|
self.assertNotIn("单镜 4–15 秒,允许不等长", text)
|
||||||
|
|
||||||
|
|
||||||
|
class RecommendScriptSetupTests(SimpleTestCase):
|
||||||
|
def test_exact_category_beauty(self):
|
||||||
|
rec = recommend_script_setup("美妆个护", "精华")
|
||||||
|
self.assertEqual(rec["format"], "oral")
|
||||||
|
self.assertEqual(rec["structure"], "contrast")
|
||||||
|
self.assertEqual(rec["persona"], "bestie")
|
||||||
|
self.assertIn("美妆个护", rec["reason"])
|
||||||
|
|
||||||
|
def test_digital_uses_reviewer(self):
|
||||||
|
rec = recommend_script_setup("数码家电")
|
||||||
|
self.assertEqual(rec["format"], "oral")
|
||||||
|
self.assertEqual(rec["structure"], "review")
|
||||||
|
self.assertEqual(rec["persona"], "reviewer")
|
||||||
|
|
||||||
|
def test_food_uses_oral_scene_bestie(self):
|
||||||
|
rec = recommend_script_setup("食品饮料", "轻醒咖啡")
|
||||||
|
self.assertEqual(rec["format"], "oral")
|
||||||
|
self.assertEqual(rec["structure"], "scene")
|
||||||
|
self.assertEqual(rec["persona"], "bestie")
|
||||||
|
|
||||||
|
def test_legacy_keyword_fallback(self):
|
||||||
|
rec = recommend_script_setup("服饰内衣", "连衣裙")
|
||||||
|
self.assertEqual(rec["format"], "oral")
|
||||||
|
self.assertEqual(rec["structure"], "scene")
|
||||||
|
self.assertEqual(rec["persona"], "urban")
|
||||||
|
|
||||||
|
def test_never_recommends_vlog_or_drama(self):
|
||||||
|
for category in (
|
||||||
|
"美妆个护", "食品饮料", "服饰鞋包", "家居日用", "数码家电",
|
||||||
|
"母婴玩具", "运动健康", "珠宝配饰", "汽车用品", "宠物用品",
|
||||||
|
"图书文教", "五金农资", "其他商品",
|
||||||
|
):
|
||||||
|
rec = recommend_script_setup(category)
|
||||||
|
self.assertEqual(rec["format"], "oral", category)
|
||||||
|
|
||||||
|
def test_empty_falls_back_to_generic(self):
|
||||||
|
rec = recommend_script_setup("", "")
|
||||||
|
self.assertEqual(rec["format"], "oral")
|
||||||
|
self.assertEqual(rec["structure"], "pain")
|
||||||
|
self.assertEqual(rec["persona"], "urban")
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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, ("穿上", "穿着", "换上", "佩戴", "戴上", "戴着")),
|
||||||
|
|||||||
@@ -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 挂载。
|
||||||
|
|||||||
@@ -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):
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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":
|
||||||
|
|||||||
@@ -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"/>'
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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";
|
||||||
|
|||||||
@@ -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";
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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; }
|
||||||
|
|||||||
@@ -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" },
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|||||||
@@ -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";
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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">
|
||||||
|
|||||||
@@ -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) {
|
||||||
|
|||||||
@@ -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}
|
||||||
|
|||||||
@@ -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":
|
||||||
|
|||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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
Reference in New Issue
Block a user