diff --git a/core/backend/apps/ai/test_video_replace.py b/core/backend/apps/ai/test_video_replace.py index 291f709..739e34c 100644 --- a/core/backend/apps/ai/test_video_replace.py +++ b/core/backend/apps/ai/test_video_replace.py @@ -2,6 +2,7 @@ 运行:DB_ENGINE=sqlite python manage.py test apps.ai.test_video_replace --settings=airshelf.settings.test """ +import json import tempfile from pathlib import Path from unittest.mock import MagicMock, patch @@ -495,12 +496,19 @@ class SubmitVideoReplaceTests(TestCase): self.assertIn("蓝牙耳机", request) self.assertIn("旧牌精华", request) # 参考动作仅作为要剥离的输入 self.assertIn("目标商品的外观、颜色、材质、形状", messages[0]["content"]) - self.assertIn("安全展示兜底", request) self.assertIn("切换顺序和剪辑点必须与拆解稿完全一致", request) self.assertIn("绝不能改成旁白", request) self.assertIn("不得说出新商品名称", request) - self.assertIn("不要机械复刻原商品交互", request) self.assertIn("保留原口播的句数、时间位置、语速节奏和情绪", request) + # 同族继承:化妆品换化妆品必须照拍原使用动作,不许退化成静物摆拍。 + # 这几条是「摆桌子」问题的修复点,别再删回去。 + self.assertIn("同族判定", request) + self.assertIn("绝不允许降级成静物展示", request) + self.assertIn("静物摆拍", request) + self.assertIn("画面动作照旧按同族判定继承", request) + self.assertIn("同族判定", messages[0]["content"]) + # 资料不全只该收紧口播,不该改画面动作 + self.assertIn("只约束口播能说什么", messages[0]["content"]) self.assertEqual(validate_product_semantic_remix(SEMANTIC_SAMPLE), SEMANTIC_SAMPLE.strip()) compiled = validate_product_semantic_remix( "原商品依赖分析(内部结果)\n【SEEDANCE_PROMPT】\n" + SEMANTIC_SAMPLE @@ -513,6 +521,38 @@ class SubmitVideoReplaceTests(TestCase): self.assertIn("画内人物对白", fallback) self.assertNotIn("旧牌精华", fallback) self.assertNotIn("倒进玻璃杯", fallback) + # 兜底也要保留原片动作结构,不能一律「放到桌上转一转」 + self.assertIn("保留原镜的人物动作结构", fallback) + self.assertNotIn("放在桌面上", fallback) + self.assertNotIn("桌面陈列特写", fallback) + + def test_product_facts_never_downgrade_actions_to_tabletop_showcase(self): + """资料不全只收紧口播,不能把动作降级成摆拍。 + + 这条守的是「上传化妆品,出片却是摆在桌上转一转」那个 bug: + 以前 facts 里带一条「资料不足时策略 = 只允许拿取/放置/桌面陈列」, + 而商品只填标题(最常见的情况)就会被判「不足」,于是原片的涂抹镜被系统 + 主动改成静物摆拍。现在资料状态只管口播,动作一律走同族继承。 + """ + from apps.ai.video_replace import _product_semantic_facts + + facts = _product_semantic_facts(self.product) + # 只填了标题 + 图片的商品,不再被打成「不足」 + self.assertEqual(facts["资料状态"], "仅外观") + self.assertNotIn("资料不足时策略", facts) + self.assertIn("同族继承优先", facts["动作策略"]) + self.assertIn("只约束口播", facts["资料状态的含义"]) + blob = json.dumps(facts, ensure_ascii=False) + self.assertNotIn("桌面陈列", blob) + + self.product.category = "护肤" + self.product.description = "补水精华" + self.product.save(update_fields=["category", "description"]) + self.assertEqual(_product_semantic_facts(self.product)["资料状态"], "完整") + + self.product.description = "" + self.product.save(update_fields=["description"]) + self.assertEqual(_product_semantic_facts(self.product)["资料状态"], "部分") def test_product_semantic_remix_falls_back_to_safe_showcase_on_missing_facts(self): """当 LLM 返回 MISSING_PRODUCT_FACTS 时,不抛错,而是平滑兜底为安全展示分镜。""" @@ -522,13 +562,13 @@ class SubmitVideoReplaceTests(TestCase): prompt = _real_rewrite_product_semantic_remix( task=None, digest_text=DIGEST_SAMPLE, - product_facts={"商品名称": "随手拍水杯", "资料状态": "不足"}, + product_facts={"商品名称": "随手拍水杯", "资料状态": "仅外观"}, duration=8, aspect_ratio="9:16", ) self.assertIn("@目标商品", prompt) self.assertNotIn("随手拍水杯", prompt) - self.assertIn("只按参考图展示外观、包装和材质", prompt) + self.assertIn("外观、包装、材质和形态以参考图为准", prompt) self.assertNotIn("旧牌精华", prompt) def test_product_triview_is_sent_with_the_video(self): diff --git a/core/backend/apps/ai/video_replace.py b/core/backend/apps/ai/video_replace.py index 3bf8dfc..497e80c 100644 --- a/core/backend/apps/ai/video_replace.py +++ b/core/backend/apps/ai/video_replace.py @@ -376,24 +376,42 @@ def load_product_semantic_remix_skill() -> str: def _product_semantic_facts(product: Product) -> dict: - """只取商品库中已填写的事实,绝不从商品图片猜用途或功效。""" + """商品库中已填写的事实 + 动作继承策略。 + + ⚠️ 「资料不足」只限制**能说什么**(功效/成分/卖点),不限制**能拍什么动作**。 + 商品的物理形态(瓶/软管/按压泵/滴管/膏体/盒装)从商品图就能直接看出来, + 是可见事实而非推断;拿取、旋开、按压、涂抹这类动作由形态决定,不由功效决定。 + 早期版本把两者混为一谈,资料一不全就把原片的使用镜全改成「摆在桌上转一转」, + 化妆品复刻化妆品也会退化成静物摆拍 —— 这里明确拆开两个维度。 + """ selling_points = [ {"标题": point.title.strip(), "说明": point.detail.strip()} for point in product.selling_points.all() if point.title.strip() or point.detail.strip() ] + category = product.category.strip() + has_text = bool(product.description.strip() or selling_points) + if category and has_text: + facts_status = "完整" + elif category or has_text: + facts_status = "部分" + else: + facts_status = "仅外观" return { "商品名称": product.title.strip(), "品牌": product.brand.strip(), - "商品品类": product.category.strip(), + "商品品类": category, "商品描述": product.description.strip(), "真实卖点": selling_points, "目标用户": product.target_audience.strip(), "规格": product.specs or {}, "允许使用场景": (product.specs or {}).get("allowed_use_cases") or (product.specs or {}).get("使用场景") or [], "禁止宣称": (product.specs or {}).get("prohibited_claims") or (product.specs or {}).get("禁止宣称") or [], - "资料状态": "完整" if (product.category.strip() and (product.description.strip() or selling_points)) else "不足", - "资料不足时策略": "安全展示兜底:只允许拿取、手持展示、放置、携带、包装/材质/外观特写和在桌面陈列;禁止打开、食用、佩戴、插入、涂抹、连接、充电、清洁、宣称功效或任何未确认用途。", + "资料状态": facts_status, + "资料状态的含义": "该状态只约束口播能说什么(功效、成分、卖点必须有据);不约束画面能拍什么动作。", + "形态判定": "商品的形态、部件、开合方式和使用接口以商品参考图为准,可直接据图判断,这属于可见事实,不是猜测。", + "动作策略": "同族继承优先:目标商品与参考片原商品属于同一形态族或品类族时,逐镜沿用原片的取出、开启、使用、作用部位等动作,只替换商品外观与台词事实。仅当形态确实不兼容时,才按目标商品形态重新设计动作。", + "禁止推断": "不得编造功效、成分、认证、检测结果,以及任何未在本资料中出现的卖点。", } @@ -409,23 +427,33 @@ def build_product_semantic_remix_messages(*, digest_text: str, product_facts: di f"成片时长:{duration} 秒;画面比例:{aspect_ratio}。\n\n" "【新商品的唯一事实来源】\n" f"{facts}\n\n" - "【参考视频拆解稿,仅可继承角色关系、环境、镜头语言、剪辑节奏和叙事功能】\n" + "【参考视频拆解稿:镜头骨架、叙事结构与画面表达的基准。" + "同族判定成立时,其中的商品使用动作也在可继承范围内】\n" f"{_digest_for_seedance(digest_text)}\n\n" - "保留参考片的人物、场景、拍摄视角、镜头构图、运镜、剪辑顺序、节奏、情绪和营销结构;将原商品完整替换为目标商品。" - "先在内部完成原商品生态、依赖道具、使用接口、动作链与逐镜兼容性检查;不要机械复刻原商品交互。" - "原镜用于取出、展示、使用或证明卖点时,保留该镜景别、运镜、时长和表达目的," - "但仅在动作适合目标商品时保留,否则按目标商品结构、用途和卖点重新设计合理动作。" + "保留参考片的人物、场景、拍摄视角、镜头构图、运镜、剪辑顺序、节奏、情绪和营销结构;将原商品完整替换为目标商品。\n" + "【第一步·同族判定】先对比目标商品与参考片原商品的形态族(瓶、软管、按压泵、滴管、喷雾、罐、盒、" + "卡片、穿戴件、电子设备等)和品类族(护肤彩妆、食品饮料、日化清洁、数码电子、服饰鞋包、家居用品等)," + "判定属于同族还是异族,再决定每一镜怎么写:\n" + "· 判定为同族:逐镜沿用原片的取出、开启、使用方式、作用部位和动作幅度,只把商品外观和台词事实换掉。" + "原片是化妆品的挤出、按压、上脸、涂抹、试色、补妆镜时,目标商品同为化妆品就必须照着拍同样的使用动作," + "只是换成目标商品的瓶身、质地和取用方式;绝不允许降级成静物展示。\n" + "· 判定为异族:保留该镜的景别、运镜、时长和叙事功能,按目标商品自己的形态和真实用途重新设计合理动作," + "同样要拍成「在用」,而不是「在摆」。\n" "所有持握、开启、穿戴、使用、安装和展示必须符合真实物理常识:不得尺寸不匹配、虚构配件、商品变形换款变色," - "也不得生成未经商品信息支持的功能。" - "如果商品资料状态为‘不足’,不要输出 NEEDS_PRODUCT_FACTS:改用商品资料中的安全展示兜底," - "把原片所有商品使用镜改写为不涉及具体用途的展示镜头。" + "也不得生成未经商品信息支持的功能。\n" + "【资料不全时怎么办】资料状态为‘部分’或‘仅外观’时,只收紧**口播能说什么**:不说功效、成分、认证和具体卖点," + "改说看得见的外观、质地、取用手感和使用感受。**画面动作照旧按同族判定继承**," + "不得因为资料不全就把使用镜改成摆拍,也不要输出 NEEDS_PRODUCT_FACTS。\n" + "【硬性禁止】禁止无理由把原片的使用镜改写成「把商品放在桌面上」「手持缓慢转动展示」「桌面陈列特写」" + "这类静物摆拍。只有目标商品的形态确实无法完成原动作时才可以改,且必须改成目标商品自己的合理使用动作。" + "成片里出现摆拍镜的数量不得超过参考片里原本就是纯展示镜的数量。\n" "镜头数量、编号、时间区间、时长、景别、机位、运镜、切换顺序和剪辑点必须与拆解稿完全一致,绝不能增删、合并或调换镜头。" "参考镜为画内人物对白时,成片必须由画面内人物直接开口说话并口型同步,绝不能改成旁白。" "任何人声都不得说出新商品名称、品牌或旧商品名称,统一用‘这个’、‘它’等自然指代。" "必须同步重写与原商品有关的口播、音效和画面内容;保留原口播的句数、时间位置、语速节奏和情绪," "但不得保留原商品的名称、功能和卖点。" "最后只输出【SEEDANCE_PROMPT】后的中文分镜稿:保留【镜头 01】格式和每镜时间、时长、景别、机位、运镜、人声方式;" - "每镜的画面、人物动作、台词/旁白必须按新商品真实用途重写。" + "每镜的画面、人物动作、台词/旁白按同族判定结果处理 —— 同族沿用原动作只换商品,异族按目标商品形态重新设计动作。" "禁止输出分析过程、分类标签或 Markdown。" ), }, @@ -446,26 +474,34 @@ def validate_product_semantic_remix(text: str) -> str: def build_safe_product_showcase_prompt(*, digest_text: str, product_name: str) -> str: - """资料不足时的确定性兜底:不依赖模型猜用途,也绝不沿用原商品动作。""" + """模型不可用时的确定性兜底:不猜功效,但**保留原片的动作结构**。 + + ⚠️ 这里的「安全」只针对口播和功效宣称,不针对画面动作。早期版本一律写死 + 「拿起 → 放到桌上 → 转一转 → 特写」,导致模型一失败,化妆品复刻就退化成静物摆拍。 + 现在改为:沿用原镜的人物姿态、动作幅度和身体关系,由 Seedance 按参考图里 + 可见的商品形态做自然使用动作;只有口播被收紧到不谈功效。 + """ from .services import enforce_no_embedded_captions header, shots = _parse_digest(digest_text) safe_actions = ( - "人物从干净桌面拿起@目标商品,保持自然手持展示。", - "人物将@目标商品平稳放在桌面上,镜头展示包装轮廓。", - "人物缓慢转动@目标商品,展示外观、包装和材质细节。", - "镜头靠近@目标商品,拍摄包装与可见结构特写。", + "人物保持原镜的姿态和站位,自然拿起@目标商品,手部动作与原镜一致。", + "人物手持@目标商品,按其在参考图中可见的形态做自然的取用动作,身体与镜头关系保持原镜。", + "人物将@目标商品移到原镜的使用位置,保持原镜的动作幅度、朝向与节奏。", + "镜头贴近人物手中的@目标商品,拍摄外观、材质与取用细节,人物动作不中断。", ) safe_dialogues = ( - "你看这个,整体的外观和质感都很清楚。", - "我把它转过来,细节可以看得更完整。", - "从这个角度看,包装和轮廓很直观。", - "镜头靠近一点,看看它的材质细节。", + "你看这个,拿在手里的质感很清楚。", + "用起来挺顺手的,细节能看得更完整。", + "从这个角度看,它的外观和手感都很直观。", + "镜头靠近一点,材质细节看得很清楚。", ) lines = [ "【成片规则】", - "@目标商品只按参考图展示外观、包装和材质。", - "不得出现原商品、原容器、原配件、原动作、原台词、功效或任何未经确认的使用方式。", + "@目标商品的外观、包装、材质和形态以参考图为准。", + "保留原镜的人物动作结构、身体朝向、动作幅度和镜头节奏;" + "按@目标商品在参考图中可见的形态做自然使用动作,不要改成静止摆拍或桌面陈列。", + "不得出现原商品、原容器、原配件、原台词,也不得宣称任何功效、成分或未经确认的效果。", "镜头数量、时间、时长、景别、机位、运镜和切换顺序必须与参考分镜一致;不得增删、合并、拆分或调换镜头。", "所有人声禁止说出商品名称和品牌。参考镜中人物开口说话时,必须生成画内人物对白和同步口型,禁止改成旁白。", ] @@ -476,7 +512,7 @@ def build_safe_product_showcase_prompt(*, digest_text: str, product_name: str) - "", f"【镜头 {index:02d}】", *(f"{field}:{shot[field]}" for field in ("时间", "时长", "景别", "机位", "运镜") if shot.get(field)), - f"画面:保持原镜的环境氛围、人物关系与镜头节奏,只出现@目标商品作为展示主体。", + f"画面:保持原镜的环境氛围、人物关系、动作节奏与镜头调度,原商品换成@目标商品。", f"人物动作:{safe_actions[(index - 1) % len(safe_actions)]}", "人物表情:自然、专注。", f"人声方式:{'画内人物对白' if shot.get('台词/旁白') and not _is_empty_value(shot.get('台词/旁白') or '') else '无'}", @@ -485,8 +521,8 @@ def build_safe_product_showcase_prompt(*, digest_text: str, product_name: str) - if not shots: lines.extend([ "", "【镜头 01】", "时长:5秒", "景别:中近景", "机位:平视", "运镜:缓慢推近", - "画面:干净自然的环境中,@目标商品置于桌面中央。", - "人物动作:人物自然手持展示@目标商品。", + "画面:干净自然的环境中,人物手持@目标商品,保持自然的使用状态。", + "人物动作:人物按@目标商品在参考图中可见的形态做自然取用动作。", "人物表情:自然、专注。", "人声方式:画内人物对白。", "台词/旁白:画内人物对白(人物对镜开口说话,非旁白):你看这个,外观和包装细节很清楚。", @@ -701,8 +737,11 @@ def submit_video_replace(*, team, user, params: dict): subject_name, image_refs, subject_source = _temporary_image_refs(team, image_ids, noun=noun) product_facts = { "商品名称": subject_name, - "资料状态": "不足", - "说明": "临时上传图片只提供外观,未提供品类、用途或真实卖点。", + "资料状态": "仅外观", + "资料状态的含义": "该状态只约束口播能说什么(不得宣称功效、成分和卖点);不约束画面能拍什么动作。", + "形态判定": "商品的形态、部件、开合方式和使用接口以临时上传的参考图为准,可直接据图判断。", + "动作策略": "同族继承优先:参考图中的商品与参考片原商品属于同一形态族或品类族时,逐镜沿用原片的取出、开启、使用、作用部位等动作,只替换商品外观。仅当形态确实不兼容时,才按参考图形态重新设计动作。", + "禁止推断": "不得编造功效、成分、认证和任何具体卖点;口播只描述看得见的外观、质地和使用感受。", } # 角色替换是 Seedance 视频编辑:火山要求比例/时长跟随输入视频,不能由前端指定。 @@ -871,7 +910,12 @@ def run_replace_digest(task) -> None: prompt = rewrite_product_semantic_remix( task=task, digest_text=digest, - product_facts=payload.get("product_facts") or {"商品名称": subject, "资料状态": "不足"}, + product_facts=payload.get("product_facts") or { + "商品名称": subject, + "资料状态": "仅外观", + "资料状态的含义": "只约束口播能说什么;不约束画面能拍什么动作。", + "动作策略": "同族继承优先:与参考片原商品同族时,逐镜沿用原片的使用动作,只替换商品外观。", + }, duration=output_seconds, aspect_ratio=str(payload.get("aspect_ratio") or "9:16"), ) diff --git a/core/backend/skills/product-semantic-video-remix/SKILL.md b/core/backend/skills/product-semantic-video-remix/SKILL.md index bceb2b5..45019e7 100644 --- a/core/backend/skills/product-semantic-video-remix/SKILL.md +++ b/core/backend/skills/product-semantic-video-remix/SKILL.md @@ -1,6 +1,6 @@ --- name: product-semantic-video-remix -description: 跨品类商品替换的语义重构规则。参考片只提供视频表达框架,新商品事实决定动作、道具、台词与结果。 +description: 商品替换的语义重构规则。先判定目标商品与原商品是否同族:同族沿用原片动作只换商品,异族按新商品形态重设计动作。参考片提供镜头骨架,新商品事实决定台词能说什么。 --- # 商品语义重构式视频复刻 @@ -9,28 +9,49 @@ description: 跨品类商品替换的语义重构规则。参考片只提供视 保留参考视频中的人物、场景、拍摄视角、镜头构图、运镜、剪辑顺序、节奏、情绪和营销结构;将原商品完整替换为目标商品。 -参考视频只能提供角色关系、兼容环境、光线、景别、机位、运镜、剪辑节奏、情绪曲线、每镜叙事功能,以及人声出现的时间与方式。禁止继承原商品、包装、容器、配件、使用接口、动作、状态变化、作用对象、结果、卖点、品牌和台词。 +参考视频提供角色关系、兼容环境、光线、景别、机位、运镜、剪辑节奏、情绪曲线、每镜叙事功能,以及人声出现的时间与方式。**原商品的使用动作是否可以继承,由下面的同族判定决定,不是一律禁止。**无论同族异族,原商品的品牌、名称、卖点、台词文字和专属配件都禁止继承。 镜头骨架是硬约束:镜头数量、连续编号、每镜时间区间、时长、景别、机位、运镜、切换顺序和剪辑点必须与参考拆解稿逐项一致;不得合并、拆分、删减、增加或调换镜头。只允许重写与原商品绑定的画面内容、动作和人声文字。 -目标商品的外观、颜色、材质、形状、尺寸比例、包装和组件以商品图片为准;商品类别、功能、卖点和使用方式以商品信息为准。商品名称和品牌仅用于内部识别,禁止出现在任何画内对白、口播、旁白或画面文字中。 +目标商品的外观、颜色、材质、形状、尺寸比例、包装、部件和使用接口以商品图片为准 —— 据图判断形态是可见事实,不是猜测。商品类别、功能、卖点和使用方式以商品信息为准。商品名称和品牌仅用于内部识别,禁止出现在任何画内对白、口播、旁白或画面文字中。 + +## 同族判定(第一步,决定后面每一镜怎么写) + +先对比目标商品与参考片原商品: + +- **形态族**:瓶、软管、按压泵、滴管、喷雾、罐、盒、卡片、穿戴件、电子设备…… +- **品类族**:护肤彩妆、食品饮料、日化清洁、数码电子、服饰鞋包、家居用品…… + +**判定为同族** —— 逐镜沿用原片的取出、开启、使用方式、作用部位、动作幅度和状态变化,只把商品外观和台词事实换掉。原片是化妆品的挤出、按压、上脸、涂抹、试色、补妆镜,目标商品同为化妆品时必须照拍同样的使用动作,只是换成目标商品的瓶身、质地和取用方式。**这种情况下降级成静物展示属于严重错误。** + +**判定为异族** —— 保留该镜的景别、运镜、时长和叙事功能,按目标商品自己的形态和真实用途重新设计合理动作。重新设计出来的仍必须是「在用」的动作,不是「在摆」的动作。 + +同族判定按镜可以有局部差异(例如同为化妆品但原片是滴管、目标是按压泵,取用手势随形态调整),但整体动作骨架仍然继承。 ## 内部工作步骤 -1. 建立原商品依赖图:主体、容器、配件、使用接口、动作、状态、结果和专属道具。 -2. 提取可迁移视频 DNA:角色、兼容环境、镜头语言、节奏、情绪曲线及每镜的 Hook / 展示 / 证明 / CTA 功能。 -3. 建立新商品能力模型:形态、部件、可用容器、允许动作、使用者、接触位置、真实卖点、禁止动作与禁止宣称。 -4. 逐镜判定:不要机械复刻原商品交互。原镜用于取出、展示、使用或证明卖点时,保留该镜的景别、运镜、时长和表达目的;只有动作适合目标商品才保留,不适合则按目标商品的结构、用途和卖点重写合理动作。原片的形态、容器、动作、接口、状态和结果只要与新商品不兼容,就必须删除;环境仅做最小必要调整。 -5. 同步重写:所有与原商品有关的口播、音效和画面内容必须改为准确描述目标商品。保留原视频口播的句数、时间位置、语速节奏和情绪,不得保留错误的商品名称、功能或卖点。 -6. 逐镜校验:人物与商品的持握、开启、穿戴、使用、安装和展示必须符合真实物理常识;商品不得从尺寸不匹配的容器中取出,不得出现不存在的配件,不得变形、换款、变色或产生未经商品信息支持的功能。动作几何、容器尺度、状态前后关系、商品外观、持有关系、卖点事实和跨镜连续性全部成立才能出片。 +1. 同族判定:按上一节比对形态族与品类族,得出整体结论和逐镜的动作继承范围。 +2. 建立原商品依赖图:主体、容器、配件、使用接口、动作、状态、结果和专属道具。 +3. 提取可迁移视频 DNA:角色、兼容环境、镜头语言、节奏、情绪曲线及每镜的 Hook / 展示 / 证明 / CTA 功能。 +4. 建立新商品能力模型:形态、部件、可用容器、允许动作、使用者、接触位置、真实卖点、禁止动作与禁止宣称。 +5. 逐镜改写:同族按原动作继承,只换商品外观与台词事实;异族按目标商品形态重设计动作,但保留该镜的景别、运镜、时长和表达目的。只有原片的容器、接口、状态或结果与目标商品确实不兼容时才删除,环境仅做最小必要调整。 +6. 同步重写人声:所有与原商品有关的口播、音效和画面内容必须改为准确描述目标商品。保留原视频口播的句数、时间位置、语速节奏和情绪,不得保留原商品的名称、功能或卖点。 +7. 逐镜校验:持握、开启、穿戴、使用、安装和展示必须符合真实物理常识;商品不得从尺寸不匹配的容器中取出,不得出现不存在的配件,不得变形、换款、变色或产生未经商品信息支持的功能。动作几何、容器尺度、状态前后关系、商品外观、持有关系、卖点事实和跨镜连续性全部成立才能出片。 -不要把“把 A 换成 B,其他不变”当成替换。最低替换单位是:商品主体 → 部件/容器/配件 → 使用接口 → 动作 → 状态变化 → 可见结果。 +异族替换时,不要把"把 A 换成 B,其他不变"当成替换。最低替换单位是:商品主体 → 部件/容器/配件 → 使用接口 → 动作 → 状态变化 → 可见结果。 -## 信息不足与失败 +## 资料不全时怎么办 -没有品类,或无法确认用途/真实卖点时,优先使用“安全展示兜底”继续:只可写拿取、手持展示、放置、携带、包装/材质/外观特写和桌面陈列;禁止打开、食用、佩戴、插入、涂抹、连接、充电、清洁、功效、结果和营销宣称。把原片所有商品使用镜改写为这些安全展示镜头,不得猜测具体用途。 +**资料状态只约束口播能说什么,不约束画面能拍什么。**这两件事必须分开处理: -即使资料不足或动作难以兼容,也必须继续输出安全展示分镜;不得输出索取资料、人工审核或拒绝生成的占位文字。 +- **收紧口播**:没有品类、描述或真实卖点时,人声不谈功效、成分、认证和检测结果,改说看得见的外观、质地、取用手感和使用感受。 +- **画面照旧**:动作仍按同族判定继承。商品的形态、开合方式和使用接口从商品参考图就能看出来,据此拍出的取用和使用动作是可见事实,不属于功效宣称。 + +只有当商品参考图本身也无法判断形态、或原动作会造成明显错误使用(尺寸不匹配、需要不存在的配件)时,该镜才降级为手持展示;即便降级,也要保留原镜的人物姿态、动作幅度和镜头关系,不要改成把商品放在桌面上的静物摆拍。 + +成片里静物摆拍镜的数量,不得超过参考片里原本就是纯展示镜的数量。 + +即使资料不足或动作难以兼容,也必须继续输出可出片的分镜;不得输出索取资料、人工审核或拒绝生成的占位文字。 ## 最终输出 @@ -39,8 +60,8 @@ description: 跨品类商品替换的语义重构规则。参考片只提供视 ``` 【SEEDANCE_PROMPT】 【成片规则】 -参考视频拆解结果仅用于角色关系、可兼容环境、镜头语言、剪辑节奏、情绪曲线和叙事结构。禁止继承原商品及其容器、配件、使用动作、作用对象、状态变化、功能结果、商品台词和专属道具。 -@目标商品:新商品外观、包装和材质参考。 +参考视频拆解结果提供角色关系、可兼容环境、镜头语言、剪辑节奏、情绪曲线和叙事结构;同族判定成立时,其商品使用动作同样照拍。禁止继承原商品的品牌、名称、卖点、台词文字和专属配件。 +@目标商品:新商品外观、包装、材质和形态参考。 【镜头 01】 时间:... @@ -48,11 +69,11 @@ description: 跨品类商品替换的语义重构规则。参考片只提供视 景别:... 机位:... 运镜:... -画面:...(新商品自己的合理道具、位置和状态) -人物动作:...(只使用允许动作) +画面:...(保持原镜的环境、人物关系与动作节奏,商品换成@目标商品) +人物动作:...(同族沿用原动作;异族按目标商品形态重设计的使用动作) 人物表情:... 人声方式:画内人物对白 / 画外旁白 / 无(必须沿用参考片该镜的人声方式) 台词/旁白:...(只使用已确认事实;画内人物对白必须让画面中的角色开口说话、口型同步,禁止改成旁白;不得说商品名称或品牌) ``` -镜头从 `【镜头 01】` 连续编号。必须保留原片的时间、时长、景别、机位、运镜、切换点、叙事功能和人声方式;重写画面、动作、台词与结果。禁止字幕、花字、原商品、原品牌、商品名称和未经证实的功效。 +镜头从 `【镜头 01】` 连续编号。必须保留原片的时间、时长、景别、机位、运镜、切换点、叙事功能和人声方式。禁止字幕、花字、原商品、原品牌、商品名称和未经证实的功效。 diff --git a/core/frontend/src/routes/video-replace.tsx b/core/frontend/src/routes/video-replace.tsx index b6064da..93af8b9 100644 --- a/core/frontend/src/routes/video-replace.tsx +++ b/core/frontend/src/routes/video-replace.tsx @@ -272,6 +272,38 @@ function modelImageCount(model: ModelEntity) { return [model.portrait, model.triview].filter(Boolean).length; } +interface ModeFormState { + videoFile: File | null; + videoPreview: string; + videoRef: FreeVideoRef | null; + videoUploading: boolean; + videoMeta: { duration: number; width: number; height: number }; + source: ProductSource; + selectedProduct: Product | null; + selectedModel: ModelEntity | null; + tempFiles: File[]; + tempAssetRefs: FreeVideoRef[]; + filledSubjectName: string; + job: FreeVideoTask | null; + jobId: string; +} + +const createInitialModeState = (): ModeFormState => ({ + videoFile: null, + videoPreview: "", + videoRef: null, + videoUploading: false, + videoMeta: { duration: 0, width: 0, height: 0 }, + source: "", + selectedProduct: null, + selectedModel: null, + tempFiles: [], + tempAssetRefs: [], + filledSubjectName: "", + job: null, + jobId: "", +}); + export function VideoReplacePage({ products: initialProducts = [], modelConfigs = [], @@ -289,26 +321,17 @@ export function VideoReplacePage({ const [products, setProducts] = useState(initialProducts); const [models, setModels] = useState([]); const [replaceMode, setReplaceMode] = useState(readReplaceMode); - const [videoFile, setVideoFile] = useState(null); - // 选完立刻用本地 objectURL 放预览,不等上传回来 —— 用户要先看见自己选的片子 - const [videoPreview, setVideoPreview] = useState(""); - const [videoRef, setVideoRef] = useState(null); - const [videoUploading, setVideoUploading] = useState(false); + // 商品复刻与角色复刻分别拥有独立表单缓存,切换模式互不干扰 + const [forms, setForms] = useState>({ + product: createInitialModeState(), + character: createInitialModeState(), + }); // 进页面先向后端确认有没有在跑的任务,确认完再决定显示表单还是进度 const [restoring, setRestoring] = useState(true); - const [videoMeta, setVideoMeta] = useState({ duration: 0, width: 0, height: 0 }); - const [source, setSource] = useState(""); - const [selectedProduct, setSelectedProduct] = useState(null); - const [selectedModel, setSelectedModel] = useState(null); - const [tempFiles, setTempFiles] = useState([]); const [tempPreviews, setTempPreviews] = useState([]); - const [tempAssetRefs, setTempAssetRefs] = useState([]); - const [filledSubjectName, setFilledSubjectName] = useState(""); const [libraryOpen, setLibraryOpen] = useState(false); const [pendingProductId, setPendingProductId] = useState(""); const [pendingModelId, setPendingModelId] = useState(""); - const [jobId, setJobId] = useState(readJobId); - const [job, setJob] = useState(null); const [history, setHistory] = useState([]); const [expandedHistoryId, setExpandedHistoryId] = useState(""); const [submitting, setSubmitting] = useState(false); @@ -318,6 +341,47 @@ export function VideoReplacePage({ const completedNoticeRef = useRef(""); const wasReviewingRef = useRef(false); const wasDigestingRef = useRef(false); + const formsRef = useRef(forms); + formsRef.current = forms; + + const currentForm = forms[replaceMode]; + const { + videoFile, + videoPreview, + videoRef, + videoUploading, + videoMeta, + source, + selectedProduct, + selectedModel, + tempFiles, + tempAssetRefs, + filledSubjectName, + job, + jobId, + } = currentForm; + + const updateCurrentForm = (updater: Partial | ((prev: ModeFormState) => Partial)) => { + setForms((prev) => { + const cur = prev[replaceMode]; + const patch = typeof updater === "function" ? updater(cur) : updater; + return { + ...prev, + [replaceMode]: { ...cur, ...patch }, + }; + }); + }; + + const updateModeForm = (mode: ReplaceMode, updater: Partial | ((prev: ModeFormState) => Partial)) => { + setForms((prev) => { + const cur = prev[mode]; + const patch = typeof updater === "function" ? updater(cur) : updater; + return { + ...prev, + [mode]: { ...cur, ...patch }, + }; + }); + }; const videoConfigs = useMemo( () => modelConfigs.filter((config) => config.capability === "video" && config.status === "active"), @@ -335,8 +399,8 @@ export function VideoReplacePage({ : source === "temporary" ? (tempFiles[0] ? (tempFiles.length > 1 - ? `${tempFiles[0].name.replace(/\.[^.]+$/, "") || copy.temporaryFallback}(${tempFiles.length}张参考图)` - : (tempFiles[0].name.replace(/\.[^.]+$/, "") || copy.temporaryFallback)) + ? `${tempFiles[0].name.replace(/\\.[^.]+$/, "") || copy.temporaryFallback}(${tempFiles.length}张参考图)` + : (tempFiles[0].name.replace(/\\.[^.]+$/, "") || copy.temporaryFallback)) : (filledSubjectName || copy.temporaryFallback)) : ""; const productReady = source === "library" @@ -413,20 +477,22 @@ export function VideoReplacePage({ if (running) { // 静默恢复:只把进度接上。不要走 fillFormFromTask —— 它会弹 toast、滚动页面, // 还依赖商品/模特列表加载完,拿来做刷新恢复会又吵又不稳。 - setJob(running); - setJobId(running.id); - rememberJob(running.id); const mode = modeFromTask(running); setReplaceMode(mode); rememberReplaceMode(mode); const video = videoRefFromTask(running); - if (video?.url) setVideoPreview(video.url); - setVideoRef(video ? { ...video, type: "video", role: "reference_video", label: video.label || "参考视频" } : null); - setFilledSubjectName(subjectNameFromTask(running)); - setVideoMeta({ - duration: Number(video?.duration || running.duration || 0), - ...sizeFromRatio(running.aspect_ratio || "9:16"), + updateModeForm(mode, { + job: running, + jobId: running.id, + videoPreview: video?.url || "", + videoRef: video ? { ...video, type: "video", role: "reference_video", label: video.label || "参考视频" } : null, + filledSubjectName: subjectNameFromTask(running), + videoMeta: { + duration: Number(video?.duration || running.duration || 0), + ...sizeFromRatio(running.aspect_ratio || "9:16"), + }, }); + rememberJob(running.id); } else { forgetJob(); } @@ -444,6 +510,14 @@ export function VideoReplacePage({ })) .catch(() => undefined); void restoreInflight(); + + return () => { + Object.values(formsRef.current).forEach((f) => { + if (f.videoPreview.startsWith("blob:")) { + try { URL.revokeObjectURL(f.videoPreview); } catch { /* ignore */ } + } + }); + }; }, []); useEffect(() => { @@ -459,20 +533,18 @@ export function VideoReplacePage({ useBodyScrollLock(libraryOpen); + const productJobId = forms.product.jobId; + const characterJobId = forms.character.jobId; + useEffect(() => { - if (!jobId) return; + if (!productJobId) return; let cancelled = false; let timer = 0; const poll = async () => { try { - const data = await api.pollVideoReplace(jobId); + const data = await api.pollVideoReplace(productJobId); if (cancelled) return; - setJob(data.task); - if (isInFlight(data.task.status)) { - const jobMode = modeFromTask(data.task); - setReplaceMode(jobMode); - rememberReplaceMode(jobMode); - } + updateModeForm("product", { job: data.task }); const stillDigesting = isDigesting(data.task); const stillReviewing = data.task.review_stage === "reviewing" || data.task.status === "created"; if (stillReviewing) wasDigestingRef.current = stillDigesting || wasDigestingRef.current; @@ -489,21 +561,20 @@ export function VideoReplacePage({ if (data.task.status === "succeeded") { if (completedNoticeRef.current !== data.task.id) { completedNoticeRef.current = data.task.id; - onNotify("success", "视频复刻成片已生成"); + onNotify("success", "商品复刻成片已生成"); onTaskSettled?.(); } void loadHistory(); } else if (data.task.status === "failed") { - onNotify("error", data.task.error_message || "视频复刻未完成,请重试"); + onNotify("error", data.task.error_message || "商品复刻未完成,请重试"); onTaskSettled?.(); } - setJobId(""); + updateModeForm("product", { jobId: "" }); forgetJob(); } catch (error) { if (cancelled) return; if (error instanceof ApiError && error.status === 404) { - setJob(null); - setJobId(""); + updateModeForm("product", { job: null, jobId: "" }); forgetJob(); return; } @@ -515,7 +586,59 @@ export function VideoReplacePage({ cancelled = true; window.clearTimeout(timer); }; - }, [jobId, onNotify, onTaskSettled]); + }, [productJobId, onNotify, onTaskSettled]); + + useEffect(() => { + if (!characterJobId) return; + let cancelled = false; + let timer = 0; + const poll = async () => { + try { + const data = await api.pollVideoReplace(characterJobId); + if (cancelled) return; + updateModeForm("character", { job: data.task }); + const stillDigesting = isDigesting(data.task); + const stillReviewing = data.task.review_stage === "reviewing" || data.task.status === "created"; + if (stillReviewing) wasDigestingRef.current = stillDigesting || wasDigestingRef.current; + if (stillReviewing) wasReviewingRef.current = true; + else if (wasReviewingRef.current && isInFlight(data.task.status)) { + wasReviewingRef.current = false; + onNotify("success", wasDigestingRef.current ? "分镜稿已提炼完成,正在复刻" : "素材审核已通过,正在复刻"); + wasDigestingRef.current = false; + } + if (isInFlight(data.task.status)) { + timer = window.setTimeout(poll, stillReviewing ? 2000 : 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?.(); + } + updateModeForm("character", { jobId: "" }); + forgetJob(); + } catch (error) { + if (cancelled) return; + if (error instanceof ApiError && error.status === 404) { + updateModeForm("character", { job: null, jobId: "" }); + forgetJob(); + return; + } + timer = window.setTimeout(poll, 8000); + } + }; + void poll(); + return () => { + cancelled = true; + window.clearTimeout(timer); + }; + }, [characterJobId, onNotify, onTaskSettled]); const pickVideo = async (file: File | null) => { if (!file) return; @@ -531,38 +654,49 @@ export function VideoReplacePage({ onNotify("error", "只支持 mp4 / mov 视频"); return; } - setVideoFile(file); - setVideoPreview(URL.createObjectURL(file)); - setVideoUploading(true); - setJob((current) => (current && isInFlight(current.status) ? current : null)); - if (job && !isInFlight(job.status)) setJob(null); + const previewUrl = URL.createObjectURL(file); + updateCurrentForm((cur) => { + if (cur.videoPreview.startsWith("blob:")) { + try { URL.revokeObjectURL(cur.videoPreview); } catch { /* ignore */ } + } + return { + videoFile: file, + videoPreview: previewUrl, + videoUploading: true, + job: cur.job && isInFlight(cur.job.status) ? cur.job : null, + }; + }); const form = new FormData(); form.append("file", file); form.append("purpose", PRODUCT_SOURCE_PURPOSE); 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, + updateCurrentForm({ + videoRef: { + 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", + }, + videoMeta: { + duration: uploaded.duration || check.duration || 0, + width: uploaded.width || 0, + height: uploaded.height || 0, + }, }); } catch (error) { - setVideoFile(null); - setVideoRef(null); - setVideoPreview(""); + updateCurrentForm({ + videoFile: null, + videoRef: null, + videoPreview: "", + }); onNotify("error", error instanceof Error ? error.message : "参考视频上传失败"); } finally { - setVideoUploading(false); + updateCurrentForm({ videoUploading: false }); } }; @@ -572,78 +706,42 @@ export function VideoReplacePage({ onNotify("error", "请选择 JPG、PNG 或 WebP 图片"); return; } - setTempFiles((current) => { - const existing = new Set(current.map(fileKey)); + updateCurrentForm((cur) => { + const existing = new Set(cur.tempFiles.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); + const room = Math.max(0, MAX_IMAGES - cur.tempFiles.length); if (room === 0) { onNotify("info", `最多上传9张${copy.temporaryNoun}`); - return current; + return {}; } if (!unique.length) { onNotify("info", "所选图片已在九宫格中"); - return current; + return {}; } if (unique.length > room) onNotify("info", `最多上传9张图片,已保留前${room}张`); - return [...current, ...unique.slice(0, room)]; + return { + tempFiles: [...cur.tempFiles, ...unique.slice(0, room)], + source: "temporary", + selectedProduct: null, + selectedModel: null, + tempAssetRefs: [], + filledSubjectName: "", + job: cur.job && !isInFlight(cur.job.status) ? null : cur.job, + }; }); - setSource("temporary"); - setSelectedProduct(null); - setSelectedModel(null); - setTempAssetRefs([]); - setFilledSubjectName(""); - if (job && !isInFlight(job.status)) setJob(null); }; - useEffect(() => { - // 换一条预览或离开页面时释放 objectURL,不然一路选下去会攒一堆 blob - if (!videoPreview.startsWith("blob:")) return; - return () => URL.revokeObjectURL(videoPreview); - }, [videoPreview]); - - const tempDrop = useFileDrop( - (files) => addTempImages(files), - { disabled: generating } - ); - - const videoDrop = useFileDrop( - (files) => { void pickVideo(files[0] || null); }, - { disabled: generating || videoUploading } - ); - const switchReplaceMode = (next: ReplaceMode) => { if (next === replaceMode || generating) return; - // 两种模式都按 30 秒收口。带着超长视频切过去会一路走到提交才报错,这里直接清掉。 - const tooLongForNext = (videoMeta.duration || 0) > REF_SECONDS_MAX[next] + 0.5; - if (tooLongForNext) { - setVideoFile(null); - setVideoRef(null); - setVideoPreview(""); - setVideoMeta({ duration: 0, width: 0, height: 0 }); - } setReplaceMode(next); rememberReplaceMode(next); - setSource(""); - setSelectedProduct(null); - setSelectedModel(null); - setTempFiles([]); - setTempAssetRefs([]); - setFilledSubjectName(""); - setPendingProductId(""); - setPendingModelId(""); setLibraryOpen(false); - if (job && !isInFlight(job.status)) setJob(null); - onNotify( - "info", - tooLongForNext - ? `已切换为${REPLACE_MODE_COPY[next].modeLabel},参考视频最长 ${REF_SECONDS_MAX[next]} 秒,请重新上传` - : `已切换为${REPLACE_MODE_COPY[next].modeLabel}` - ); + onNotify("info", `已切换为${REPLACE_MODE_COPY[next].modeLabel}`); }; const confirmLibrarySelection = () => { @@ -657,15 +755,17 @@ export function VideoReplacePage({ onNotify("error", "这个角色还没有可用图片"); return; } - setSelectedModel(model); - setSelectedProduct(null); - setSource("library"); - setTempFiles([]); - setTempAssetRefs([]); - setFilledSubjectName(""); + updateModeForm("character", (cur) => ({ + selectedModel: model, + selectedProduct: null, + source: "library", + tempFiles: [], + tempAssetRefs: [], + filledSubjectName: "", + job: cur.job && isInFlight(cur.job.status) ? cur.job : null, + })); setLibraryOpen(false); setPendingModelId(""); - if (job && !isInFlight(job.status)) setJob(null); onNotify("success", `${copy.pickToast}:${model.name}`); return; } @@ -674,41 +774,44 @@ export function VideoReplacePage({ onNotify("info", "请先选择或上传商品素材"); return; } - setSelectedProduct(product); - setSelectedModel(null); - setSource("library"); - setTempFiles([]); - setTempAssetRefs([]); - setFilledSubjectName(""); + updateModeForm("product", (cur) => ({ + selectedProduct: product, + selectedModel: null, + source: "library", + tempFiles: [], + tempAssetRefs: [], + filledSubjectName: "", + job: cur.job && isInFlight(cur.job.status) ? cur.job : null, + })); setLibraryOpen(false); setPendingProductId(""); - if (job && !isInFlight(job.status)) setJob(null); onNotify("success", `${copy.pickToast}:${product.title}`); }; const startGeneration = async () => { + const current = forms[replaceMode]; if (!videoReady || !productReady || generating) return; if (!preferredModel) { onNotify("error", "暂无可用视频模型"); return; } - if (!videoRef?.asset_id) { + if (!current.videoRef?.asset_id) { onNotify("error", "请先上传参考视频"); return; } setSubmitting(true); try { let imageAssetIds: string[] = []; - if (source === "temporary") { - if (tempFiles.length) { - for (const file of tempFiles.slice(0, MAX_IMAGES)) { + if (current.source === "temporary") { + if (current.tempFiles.length) { + for (const file of current.tempFiles.slice(0, MAX_IMAGES)) { const form = new FormData(); form.append("file", file); const data = await api.uploadFreeVideoRef(form); if (data.asset_id) imageAssetIds.push(data.asset_id); } } else { - imageAssetIds = tempAssetRefs.map((item) => item.asset_id || "").filter(Boolean); + imageAssetIds = current.tempAssetRefs.map((item) => item.asset_id || "").filter(Boolean); } if (!imageAssetIds.length) { onNotify("error", replaceMode === "character" ? "请上传角色参考图" : "请上传商品参考图"); @@ -717,19 +820,22 @@ export function VideoReplacePage({ } const data = await api.submitVideoReplace({ replace_mode: replaceMode, - video_asset_id: videoRef.asset_id, - product_id: source === "library" && replaceMode === "product" ? selectedProduct?.id : undefined, - model_id: source === "library" && replaceMode === "character" ? selectedModel?.id : undefined, - image_asset_ids: source === "temporary" ? imageAssetIds : undefined, + video_asset_id: current.videoRef.asset_id, + product_id: current.source === "library" && replaceMode === "product" ? current.selectedProduct?.id : undefined, + model_id: current.source === "library" && replaceMode === "character" ? current.selectedModel?.id : undefined, + image_asset_ids: current.source === "temporary" ? imageAssetIds : undefined, model: preferredModel.name, aspect_ratio: aspectRatio, resolution: "720p", duration: outputDuration, }); - setJob(data.task); - setJobId(data.task.id); + const taskMode = modeFromTask(data.task) || replaceMode; + updateModeForm(taskMode, { + job: data.task, + jobId: data.task.id, + }); rememberJob(data.task.id); - rememberReplaceMode(modeFromTask(data.task) || replaceMode); + rememberReplaceMode(taskMode); completedNoticeRef.current = ""; wasReviewingRef.current = data.task.review_stage === "reviewing" || data.task.status === "created"; wasDigestingRef.current = isDigesting(data.task); @@ -750,8 +856,11 @@ export function VideoReplacePage({ // 后端单飞闸:已有复刻在跑。直接接上它,别让用户对着报错干瞪眼。 const running = (error.payload as { inflight?: FreeVideoTask } | undefined)?.inflight; if (running) { - setJob(running); - setJobId(running.id); + const taskMode = modeFromTask(running); + updateModeForm(taskMode, { + job: running, + jobId: running.id, + }); rememberJob(running.id); } onNotify("info", error.message || "已有一个视频正在复刻中"); @@ -764,8 +873,8 @@ export function VideoReplacePage({ }; const fillFormFromTask = (task: FreeVideoTask) => { - if (generating) return; const mode = modeFromTask(task); + if (forms[mode].job && isInFlight(forms[mode].job.status)) return; const video = videoRefFromTask(task); if (!video?.asset_id) { onNotify("error", "这条记录没有可用的参考视频,请重新上传"); @@ -775,54 +884,55 @@ export function VideoReplacePage({ const subject = subjectNameFromTask(task); setReplaceMode(mode); rememberReplaceMode(mode); - setVideoFile(null); - // 从历史「重新生成」回填时也要把原视频放进预览框,否则第 1 步看起来像没选 - setVideoPreview(video.url || ""); - setVideoRef({ - ...video, - type: "video", - role: "reference_video", - label: video.label || "参考视频", - }); - setVideoMeta({ - duration: Number(video.duration || task.duration || 0), - ...sizeFromRatio(task.aspect_ratio || "9:16"), - }); - setFilledSubjectName(subject); + if (mode === "character") { const model = models.find((item) => item.id === task.model_id) || models.find((item) => item.name === subject); - if (model) { - setSelectedModel(model); - setSelectedProduct(null); - setSource("library"); - setTempFiles([]); - setTempAssetRefs([]); - } else { - setSelectedModel(null); - setSelectedProduct(null); - setSource(images.length ? "temporary" : ""); - setTempFiles([]); - setTempAssetRefs(images); - if (!model && task.subject_source === "library") onNotify("info", "原角色已不在人物库,请重新选择"); - } + updateModeForm("character", { + videoFile: null, + videoPreview: video.url || "", + videoRef: { + ...video, + type: "video", + role: "reference_video", + label: video.label || "参考视频", + }, + videoMeta: { + duration: Number(video.duration || task.duration || 0), + ...sizeFromRatio(task.aspect_ratio || "9:16"), + }, + filledSubjectName: subject, + selectedModel: model || null, + selectedProduct: null, + source: model ? "library" : (images.length ? "temporary" : ""), + tempFiles: [], + tempAssetRefs: model ? [] : images, + }); + if (!model && task.subject_source === "library") onNotify("info", "原角色已不在人物库,请重新选择"); } else { const product = products.find((item) => item.id === task.product_id) || products.find((item) => item.title === subject); - if (product) { - setSelectedProduct(product); - setSelectedModel(null); - setSource("library"); - setTempFiles([]); - setTempAssetRefs([]); - } else { - setSelectedProduct(null); - setSelectedModel(null); - setSource(images.length ? "temporary" : ""); - setTempFiles([]); - setTempAssetRefs(images); - if (!product && task.subject_source === "library") onNotify("info", "原商品已不在商品库,请重新选择"); - } + updateModeForm("product", { + videoFile: null, + videoPreview: video.url || "", + videoRef: { + ...video, + type: "video", + role: "reference_video", + label: video.label || "参考视频", + }, + videoMeta: { + duration: Number(video.duration || task.duration || 0), + ...sizeFromRatio(task.aspect_ratio || "9:16"), + }, + filledSubjectName: subject, + selectedProduct: product || null, + selectedModel: null, + source: product ? "library" : (images.length ? "temporary" : ""), + tempFiles: [], + tempAssetRefs: product ? [] : images, + }); + if (!product && task.subject_source === "library") onNotify("info", "原商品已不在商品库,请重新选择"); } onNotify("success", "已填入上次素材,确认后可再次生成"); document.querySelector(".replace-flow-panel")?.scrollIntoView({ behavior: "smooth", block: "start" }); @@ -844,6 +954,16 @@ export function VideoReplacePage({ setExpandedHistoryId((current) => (current === id ? "" : id)); }; + const tempDrop = useFileDrop( + (files) => addTempImages(files), + { disabled: generating } + ); + + const videoDrop = useFileDrop( + (files) => { void pickVideo(files[0] || null); }, + { disabled: generating || videoUploading } + ); + const cells = Array.from({ length: 9 }, (_, index) => tempDisplay[index] || null); return ( @@ -1036,12 +1156,17 @@ export function VideoReplacePage({ event.preventDefault(); event.stopPropagation(); if (generating) return; - if (tempFiles.length) { - setTempFiles((current) => current.filter((_, itemIndex) => itemIndex !== index)); - } else { - setTempAssetRefs((current) => current.filter((_, itemIndex) => itemIndex !== index)); - } - if (job && !isInFlight(job.status)) setJob(null); + updateCurrentForm((cur) => { + const nextFiles = cur.tempFiles.filter((_, itemIndex) => itemIndex !== index); + const nextAssetRefs = cur.tempAssetRefs.filter((_, itemIndex) => itemIndex !== index); + const nextSource = (nextFiles.length > 0 || nextAssetRefs.length > 0) ? cur.source : ""; + return { + tempFiles: nextFiles, + tempAssetRefs: nextAssetRefs, + source: nextSource, + job: cur.job && !isInFlight(cur.job.status) ? null : cur.job, + }; + }); onNotify("info", `已删除临时${copy.temporaryNoun}`); }} > @@ -1064,12 +1189,15 @@ export function VideoReplacePage({ event.preventDefault(); event.stopPropagation(); if (generating) return; - if (!tempFiles.length && !tempAssetRefs.length) return; - setTempFiles([]); - setTempAssetRefs([]); - setFilledSubjectName(""); - if (source === "temporary") setSource(""); - if (job && !isInFlight(job.status)) setJob(null); + const cur = forms[replaceMode]; + if (!cur.tempFiles.length && !cur.tempAssetRefs.length) return; + updateCurrentForm((c) => ({ + tempFiles: [], + tempAssetRefs: [], + filledSubjectName: "", + source: c.source === "temporary" ? "" : c.source, + job: c.job && !isInFlight(c.job.status) ? null : c.job, + })); onNotify("info", `已清空临时${copy.temporaryNoun}`); }} >