diff --git a/Airshelf_二期功能清单_1.md b/Airshelf_二期功能清单_1.md new file mode 100644 index 0000000..1884540 --- /dev/null +++ b/Airshelf_二期功能清单_1.md @@ -0,0 +1,152 @@ +# Airshelf 二期功能清单 + +> 由《二期需求整理总纲》拆解而来,颗粒度到「可派活/可估时」的功能项。 +> 分期:**上**=二期上半程(骨架,先做);**下**=二期下半程;**并**=可并行支线。 +> 状态:**明确**=可开工;**待确认**=需先对齐(对象见备注)。 +> 编号:模块.序号(1=主流程 2=电商内容 3=本地生活 4=自由创作 5=保存模板)。 +> 开发进度:**已完成**=代码已落地可验收;**部分完成**=已做一半,缺口见备注;**第N段**=按文末《开发分段》排期,尚未开工。 + +--- + +## 模块 1 · 主流程优化(核心骨架,对应 PDF 9 条) + + +| 编号 | 功能 | 说明 | 分期 | 状态 | 开发进度 | +| ---- | ----------------- | -------------------------------------------------------- | --- | --------------------- | ----------------------------------------------------------------------------------------------------------- | +| 1.1 | 取消项目创建阶段的视频时长选择 | 时长移到脚本阶段(见 1.9) | 上 | 明确 | **已完成**(第2段补齐)。新建向导里本就没有时长控件;脚本设定卡现在有「时长」那一栏(5–60 秒、5 秒一档),死值 60 秒已去掉 | +| 1.2 | 删除脚本创建的「AI 全生」按钮 | 流程简化 | 上 | 明确 | **已完成**(第1段) | +| 1.3 | 「一句话主题」改名「脚本辅助生成」 | 含删除一句话主题框(PDF 第4条) | 上 | 明确 | **已完成**(第1段)。改后确认风格人物即直接出稿,少一轮「追问主题」的来回 | +| 1.4 | 脚本创建三种方式入口 | 辅助生成 / 上传脚本 / **上传视频提炼**(功能A) | 上 | 明确 | **已完成**(第5段补齐)。三个入口都在脚本页空态并排;生成后脚本卡的「来源」徽标能分清是哪个入口出的稿(原先三种都记成「脚本辅助生成」) | +| 1.5 | 脚本辅助生成三选栏 | 表现形式 × 视频结构 × 时长;系统按商品信息**推荐默认值 + 保留用户选择权** | 上 | 明确 | **已完成**(第2段)。设定卡改成「表现形式 × 视频结构 × 人物 × 时长」四栏,进卡即带推荐值,每栏下方一句话说明 | +| 1.6 | 表现形式选项 | 口播 / 短剧 / Vlog | 上 | 待确认(需求方:选项是否定死/可增删) | **已完成**(第2段)。三个选项前后端共用一份枚举,加一个 = 前后端各加一行 + 补一份套路文档 | +| 1.7 | 视频结构选项 | 痛点解决 / 前后对比 / 测评验证 / 场景种草 | 上 | 待确认(同上) | **已完成**(第2段)。同 1.6,可增删 | +| 1.8 | 组合联动筛选 | 表现形式为主,视频结构按其筛选,禁矛盾组合(如口播+短剧) | 上 | 待确认(产品:组合规则) | **已完成**(第2段)。表现形式为主筛结构;目前只禁一组「短剧 × 测评验证」(演出来的实测没可信度),要加规则改一张表即可 | +| 1.9 | 时长逻辑 | **复用另一平台现成「完全自由 5s 步长 5–60s」实现** + 表现形式推荐默认;后端单镜≤15s 切镜 | 上 | 待确认(开发:可移植/单镜约束) | **已完成**(第2段,并进来一起做了)。总时长 5–60 秒 5 秒一档,单镜 4–15 秒不等长、加总精确等于总时长;写死 15 秒的六处已全打掉,脚本镜时长会同步到视频段(已出片的段不动)。真跑验证:40 秒短剧×痛点 → 5 镜 8/6/10/8/8;15 秒口播×前后对比 → 3 镜 4/7/4 | +| 1.10 | 上传脚本 | 仅 docx/txt;平台识别结构后**优化、补齐钩子/卖点证明/转化内容** | 上 | 明确 | **已完成**(第1段)。docx 用标准库在后端拆,没加新依赖;提示词已改为先识别结构、保留原意,再补钩子/卖点证明/转化 | +| 1.11 | 上传视频提炼(功能A) | 视频→结构化分镜(对齐画面描述七要素)→**可人工逐镜编辑**→喂脚本。⚠️必须可编辑(AI拆解有5类已知毛病) | 上 | 待确认(客户:是否要"迁移到新商品"步骤) | **已完成**(第5段)。**开工前那句「唯一一条新基建」经核实是高估了**:现在的脚本模型本身就会读图(已实测),拆视频的 ffmpeg 也早就装在后端(第5阶段导出一直在用),真正新写的只有「按时间轴均匀抽帧」这一小段。链路:传视频 → 抽 4–12 帧 → 模型逐镜还原 → **中文分镜稿直接落在输入框里让人改** → 改完才交给脚本 agent 按你自己的商品重写。上限 3 分钟 / 200 MB,整条约 30–60 秒,计一次文本生成的费。**语音转写没做**,理由见下方「第5段」说明 | +| 1.12 | 三视图资产:双通道 | **AI 生成 + 用户上传 均保留**;样式调整:单独上传框、取消替换按钮 | 上 | 明确 | **已完成**(第1段)。替换按钮已删,改成「上传三视图」直接选文件;还没图时预览区本身就是虚线上传框 | +| 1.13 | 三视图:重新生成入口确认 | 「取消替换按钮」不等于取消「重新生成三视图」能力,需保留生成入口 | 上 | 待确认(开发) | **已完成**(第1段)。开发侧结论:**不受影响**——「AI 重新生成三视图」和「替换」本就是两个独立按钮,删一个不动另一个,预览面板里的「重跑」也在 | +| 1.14 | 故事板进页面自动生成 | 不再手动点「开始生成」;**防刷新/返回/切页重复创建** | 上 | 明确 | **暂缓,需先补后端防重复**。这条听起来是「删掉一个手动按钮」,但那个按钮正是目前唯一的防重复措施——后端收到一次请求就新建一次任务、预扣一次积分,本身不检查「这段是不是已经在跑了」。改成进页面自动触发后,用户每刷新一次页面就重新触发一次。前置工作 = 给后端补「已经在跑就不重复建」的判断,对用户不可见,但省掉它这功能不能上线 | +| 1.15 | 视频进页面自动生成 | 同 1.14;防重复创建视频任务 | 上 | 明确 | **暂缓,风险高于 1.14**。视频是全流程最贵的一步,而提交侧后端**完全没有**防重复:点两次就是两个任务、扣两份积分。现在只有前端按钮禁用挡着,绕开它(刷新、开两个标签页、点太快)就会重扣。**不补后端就上线 = 用户刷新一次重扣一批**。补法与 1.14 同源,视频优先做 | +| 1.16 | 全流程积分消耗展示 | 生成前预估 + 结束后明细。⚠️依赖各环节**计费标准先定清** | 上 | 待确认(PM:计费标准) | **部分完成,余下待 PM**。余额、每步单价、单任务预估、实际扣除、消费流水都已经有了;缺的只是流程级汇总(进条前总预估 + 出片后一次性明细)。⚠️这个汇总**不能让前端把各步单价加起来自己算**:视频按实际时长和用量动态计价,还有重跑次数、团队折扣两步取整,前端算的数跟最后真扣的对不上,用户会觉得平台乱扣钱。得先定清计费口径,再由后端出数 | +| 1.17 | 保存模板 | 见模块 5(同一功能) | 下 | — | 第7段(见模块 5) | + + +--- + + + +## 模块 2 · 电商脚本内容(并入模块1,内容层) + + +| 编号 | 功能 | 说明 | 分期 | 状态 | 开发进度 | +| --- | ------------------ | ---------------------------------- | --- | ------------- | ----------------------------------------------------------------- | +| 2.1 | 表现形式 × 视频结构 套路内容沉淀 | 每个(合理)组合对应一份套路:结构骨架、镜头逻辑、钩子/CTA 规则 | 上 | 待确认(PM:内容谁沉淀) | **草稿已落地**(第2段)。3 份表现形式 + 4 份视频结构 + 1 份组合矩阵,共 8 份套路文档已进技能库并生效。**内容质量待 PM / 内容同学过一遍**,改的是 md 文档,不用动代码 | +| 2.2 | 电商专家知识 v0.1 → 扩展 | 已有客户 SOP + 益生菌案例打底,扩为多组合 | 上 | 待确认(内容归属) | **已完成**(第2段)。技能库新增「表现形式 × 视频结构」两个维度,按用户所选**只加载对应的两份套路**,不再一次性灌全部提示词 | +| 2.3 | 系统按商品信息推荐默认组合 | 品类/人群/卖点 → 推荐表现形式+结构默认值 | 上 | 明确 | **已完成**(第2段)。按品类关键词推荐组合 + 时长(如数码 3C → 口播 × 测评验证 · 30 秒),推荐值可随时改,「重新推荐」可退回 | + + +--- + + + +## 模块 3 · 本地生活(依赖模块1先落地) + + +| 编号 | 功能 | 说明 | 分期 | 状态 | 开发进度 | +| --- | ------------- | ----------------------------------- | --- | --------------- | ----------------------------------------------------------------------------- | +| 3.1 | 商品库新增「类目」字段 | 电商/本地生活;驱动后续挂 skill 与节点屏蔽。⚠️涉及商品库结构 | 下 | 待确认(PM/开发:范围排期) | 第8段。⚠️**不能复用现有「品类」字段**:商品库已有的品类是美妆/食品那种细分,「电商 / 本地生活」是另一个维度,必须新开字段,混用会打乱商品库筛选 | +| 3.2 | 团购 = 虚拟商品建模 | 无实物商品,沿用主流程 SOP | 下 | 明确 | 第8段 | +| 3.3 | 探店脚本结构 + 镜头语言 | 达人→门店→点餐→试吃→环境→到店核销;探店镜头语言 | 下 | 待确认(内容原料待补) | 第8段。与 2.1/2.2 同类:是**内容活不是代码活**,得先有人写 | +| 3.4 | 本地生活节点屏蔽 | 三视图可跳过、模特上身图入口隐藏(按类目标签自动) | 下 | 明确 | 第8段(依赖 3.1 的新字段) | +| 3.5 | 是否覆盖图片线(平台套图) | 覆盖则平台清单换(美团/点评/抖音本地生活…) | 下 | 待确认(需求方:范围) | 第8段 | + + +--- + + + +## 模块 4 · 自由创作打通(相对独立,可并行) + + +| 编号 | 功能 | 说明 | 分期 | 状态 | 开发进度 | +| --- | ----------- | ------------------------------------ | --- | --- | --------------------------------------------------------------------- | +| 4.1 | 三库接入自由创作 | 商品库/模特库/资产库可 @引用(现状完全不能引用) | 并 | 明确 | **已完成(第6段)**。自由创作页新增「引用平台素材」入口(页头按钮 + 输入条图标),弹窗里三个 tab 切换资产库 / 模特库 / 商品库;商品要先点进商品再挑它的素材;模特卡上直接给「形象图 / 三视图」两个引用按钮。挑中的素材和上传的一样进参考区、支持 @ 引用,首尾帧模式也能用 | +| 4.2 | 审核状态跟资产走机制 | 平台生成=已审免审;用户上传=审一次打标记;**避免按库免审架空审核** | 并 | 明确 | **已完成(第6段)**。判断只看素材的「来源」和「审核状态」两个字段,**不看它在不在库里**——按库免审就是架空审核:用户传一张图进资产库、再从自由创作引用出去,整套人像审核就白做了。表现:平台生成的直接可用;用户上传的没审过会显示黄色「待送审」并挡住,卡片上给一个「送审」按钮,审核通过后自动变成可引用。审核中/未通过都有明确文案,不会让用户一脸问号 | +| 4.3 | 分步实施 | 资产库先行(最干净)→ 模特库 → 商品库 | 并 | 明确 | **已完成(第6段)**。三个库一次接完。后端只认一种「引用平台素材」,三个库的差别全在挑素材的界面上——以后再加第四个库也不用动后端 | +| 4.4 | 存量资产补审核状态标记 | 数据迁移:平台生成批量标已审、用户上传标待审 | 并 | 明确 | **跳过,不需要做**。原计划是跑一次数据迁移给老资产批量打标记,实际不用:4.2 的判断是「按来源实时算」,平台生成的老资产天然免审、用户上传的老资产天然显示「待送审」,不刷数据结论也一样。**少跑一次全表刷新 = 少一个出错的机会**,故不做 | + + +--- + + + +## 模块 5 · 保存模板 / 视频替换(PDF 第9条) + + +| 编号 | 功能 | 说明 | 分期 | 状态 | 开发进度 | +| --- | -------------- | --------------------------------------------- | --- | -------------- | ------------------------------------------------------------------ | +| 5.1 | 保存模板 | 存带货套路/脚本结构/人物场景风格/镜头节奏/转化方式 | 下 | 明确 | 第7段。**必须等第2、3段定稿**:模板存的就是「表现形式 × 视频结构 × 时长」那套参数,参数还没定型就先做模板,改一次废一次 | +| 5.2 | 换商品重跑 pipeline | 换商品后重做商品理解、卖点适配、资产生成、故事板生成(=满足「视频商品替换」需求,路径②) | 下 | 明确 | 第7段 | +| 5.3 | 视频替换预期管理 | 产出为「同套路重出新视频」,非「仅换商品的原片」,需告知用户 | 下 | 待确认(客户:替换确切含义) | 第7段。是文案/沟通活,代码量很小,但**要在客户签字前说清楚**,否则验收时对不上 | +| 5.4 | 模板私有 vs 平台公共库 | 公共库价值大但复杂 | 下 | 待确认(PM) | 第7段。建议先只做私有模板,公共库另立一期 | + + +--- + + + +## 明确不做(防蔓延,一并记录) + + +| 项 | 处置 | +| ------------- | ------------------ | +| 模特库改造 | 独立立项(已备《模特库机制设计》) | +| 商品链接解析入库 | backlog(爬虫维护+合规风险) | +| 一键白底图 | backlog(定位漂移) | +| 视频成片直接替换(路径①) | 不做,用模块5 路径②替代 | + + +--- + + + +## 待确认汇总(按对象,便于对齐) + +**需求方 / 客户**:1.6/1.7 选项是否可增删 · ~~1.11 是否要迁移到新商品~~(**已按「要迁移」实现**:拆完的分镜稿会自动按当前项目的商品重写,参考视频里的商品/品牌/人物一律不保留;若客户其实想要「原样还原参考片」,回来改一句提示词即可)· 1.11 是否要接语音转写拿原片口播(**当前没接**,理由见第5段) · 3.5 是否覆盖图片线 · 5.3 替换确切含义 · 3.3 本地生活案例原料 +**PM**:2.1/2.2 内容谁沉淀(★最高)· 1.16 计费标准 · 3.1 商品库改动排期 · 5.4 模板公共化 · 全局分期 +**开发**:1.9 时长现成方案可移植/单镜约束 ~~(已验证可移植,见 1.9 进度栏)~~ · 1.13 三视图重新生成入口 ~~(已确认不受影响)~~ · 4.x 自由创作从0做引用工作量 + +--- + + + +## 开发分段(「开发进度」列里「第N段」的含义) + + +| 段 | 内容 | 说明 | +| ------- | --------------------------------------------------- | ------------------------- | +| **第1段** | 脚本入口清扫:1.2 · 1.3 · 1.4(上传脚本部分) · 1.10 · 1.12 · 1.13 | 无前置依赖,纯前端 + 一个小接口。**已完成** | +| **第2段** | 表现形式 × 视频结构 + 时长:1.5 · 1.6 · 1.7 · 1.8 · 2.1 · 2.2 · 2.3 · 1.9 · 1.1 | 第3段的时长并进来一起做了(同一排选项,不搭两遍)。**已完成**,2.1 套路内容是草稿,待内容同学过一遍 | +| ~~**第3段**~~ | ~~时长自由化:1.9 + 补 1.1 遗留的脚本阶段时长控件~~ | **已并入第2段** | +| ~~**第4段**~~ | ~~自动生成 + 积分汇总:1.14 · 1.15 · 1.16~~ | **暂缓**。1.14/1.15 的前置(后端防重复)没做完之前不开工,否则用户刷新一次重扣一批;1.16 等 PM 定计费口径 | +| **第5段** | 上传视频提炼:1.11 · 补 1.4 的第三个入口 | **已完成**。原判「唯一一条新基建」偏保守:读图能力和 ffmpeg 都是现成的,实际只新写了抽帧。**两件要跟需求方说清的**:① **没接语音转写** —— 拆解只看画面,口播词只能从画面上的字幕读;带货参考片绝大多数带硬字幕,且口播本来就要按新商品重写,所以先不接。真要原片口播,需另开火山语音识别服务(新凭证 + 新计价,约 1 段工作量)。② **拆解稿是给人改的,不是直接出片** —— 稿子落在输入框里,用户逐镜校对完再点确定;AI 自己会在稿尾列「拆解存疑」提示该重点核对哪几条 | +| **第6段** | 自由创作接三库:模块 4 | 独立支线,不碰主流程 → 提前做掉。**已完成**(4.4 数据迁移经判断不需要做,理由见该行) | +| **第7段** | 保存模板 + 换商品重跑:模块 5(含 1.17) | 等第2、3段参数定稿 | +| **第8段** | 本地生活:模块 3 | 依赖主流程先稳,放最后 | + + +主链:**第1 → 2 → 6 → 5 → 7**(第1、2、5、6 段已完成;第3段已并入第2段;第4段暂缓,等后端防重复补完 + PM 定计费口径再回来);第8段放最后。 + +**至此模块1(脚本)除 1.14/1.15/1.16 三条暂缓项外全部做完**,三个脚本入口都通了。下一站是第7段(保存模板 + 换商品重跑,模块5)。 + +### ⚠️ 需要跟 PM / 客户提前说清楚的四条(听起来简单、实际有雷) + +0. **1.11「画面描述七要素」的具体七项,需求文档里没给**。开发按「下游重拍时最少需要哪些信息」定了这七项,**请需求方比对确认**:① 主体(谁/什么在画面里)② 动作 ③ 场景/环境 ④ 景别(特写/中景/全景)⑤ 运镜(固定/推拉摇移/跟拍)⑥ 光线氛围 ⑦ 商品露出方式(手持/特写/使用中/未出现)。这七项是照着平台脚本已有的字段挑的,**改一项要连着改脚本结构**,所以趁早对齐;纯加一项文字描述则很便宜。 +1. **1.14 / 1.15 自动生成** —— 见上,删按钮 = 拆掉唯一的防重复措施,会重复扣积分。**已暂缓**。 +2. **5.3 视频替换** —— 客户很可能理解成「原片不动,只把里面的商品换掉」,平台实际能做的是「沿用同一套路,重新生成一条新视频」。这两件事在客户脑子里是一回事,在验收现场是两回事。**代码量很小,说不清楚的代价是整批返工**,务必在签字前对齐。 +3. **3.1 商品库「类目」字段** —— 要加的「电商 / 本地生活」跟商品库已有的「品类」(美妆/食品那种细分)是两个维度,**必须新开字段**。图省事塞进同一个字段,现有商品库筛选会全乱。 + +--- + +*功能清单 v1。与《二期需求整理总纲》配套,随分期与对齐结论更新。* \ No newline at end of file diff --git a/core/backend/apps/ai/free_video.py b/core/backend/apps/ai/free_video.py index 1fa40e7..6d6aacd 100644 --- a/core/backend/apps/ai/free_video.py +++ b/core/backend/apps/ai/free_video.py @@ -99,6 +99,31 @@ def _refresh_processing_free_asset(free_asset: FreeAsset) -> bool: return False +def _guard_asset_reference(asset: Asset, label: str) -> None: + """三库引用前的审核闸(模块4 · 4.2)。 + + 平台生成的资产免审直接过;用户上传的必须真过一遍审核才能当生成参考。 + 判据是 Asset.source,**不是**「在不在资产库里」—— 按库免审等于把审核架空: + 用户传一张图进库、再从自由创作引用出去,就绕过了整套人像审核。 + """ + from apps.assets.review import poll_asset_review, reference_review_state, submit_asset_for_review + + state = reference_review_state(asset) + if state == "processing": + poll_asset_review(asset) # 实时刷一次,别让用户干等下一轮轮询 + state = reference_review_state(asset) + if state == "allowed": + return + name = label or asset.name or "未命名" + if state == "processing": + raise ValueError(f"素材「{name}」正在审核中,请稍后再引用") + if state == "failed": + raise ValueError(f"素材「{name}」未通过审核,不能用作生成参考") + # 从没送过审(资产库上传不自动送审):这里补送一次,用户等审核结果即可,不必回去手动点 + submit_asset_for_review(asset, force=True) + raise ValueError(f"素材「{name}」是上传素材,已提交审核,通过后即可引用") + + def build_content_items(*, team, prompt: str, mode: str, references: list) -> dict: """references → 火山 content_items + api_prompt(@label 已替换)。 @@ -244,6 +269,32 @@ def build_content_items(*, team, prompt: str, mode: str, references: list) -> di _remember_library_asset(fa) continue + # 三库引用(模块4 · 4.1):资产库 / 模特库 / 商品库 挑出来的东西最终都是一行 Asset, + # 所以后端只认一种 source=asset,三个库的差别全在前端的 picker 上。 + if source == "asset" and ref.get("asset_id"): + asset = Asset.objects.filter(id=ref["asset_id"], team=team, is_deleted=False).first() + if asset is None: + raise ValueError(f"素材「{label or '未命名'}」不存在或已被删除") + _guard_asset_reference(asset, label) + from .services import _asset_preview_url, _seedance_ref_url + + raw_url = _asset_preview_url(asset) + if not raw_url: + raise ValueError(f"素材「{label or asset.name}」没有可用文件,无法引用") + # 已登记火山素材库的走 asset://(写实人脸走直链会被 InputImageSensitiveContentDetected 拒) + resolved_url = _seedance_ref_url(raw_url, asset.review_status, asset.review_remote_id) + kind = asset.asset_type if asset.asset_type in {"image", "video", "audio"} else "image" + if mode == "keyframe": + if kind != "image": + raise ValueError("首尾帧模式仅支持图片素材") + effective_role = role if role in {"first_frame", "last_frame"} else "first_frame" + else: + effective_role = "reference_video" if kind == "video" else ("reference_audio" if kind == "audio" else "reference_image") + asset_type = _push(kind, resolved_url, effective_role, duration) + if label and label not in label_to_placeholder: + label_to_placeholder[label] = _placeholder_for(asset_type) + continue + # 直传素材(已上传 TOS 的直链) if ref_type == "image": # 参考图模式下所有图 role 必须 reference_image;keyframe 用 first_frame/last_frame diff --git a/core/backend/apps/ai/migrations/0030_alter_aitask_task_type.py b/core/backend/apps/ai/migrations/0030_alter_aitask_task_type.py new file mode 100644 index 0000000..a5bf85a --- /dev/null +++ b/core/backend/apps/ai/migrations/0030_alter_aitask_task_type.py @@ -0,0 +1,18 @@ +# Generated by Django 5.1.15 on 2026-08-17 09:03 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('ai', '0029_aimodelattempt'), + ] + + operations = [ + migrations.AlterField( + model_name='aitask', + name='task_type', + field=models.CharField(choices=[('script_generation', 'Script Generation'), ('script_optimization', 'Script Optimization'), ('entity_extraction', 'Entity Extraction'), ('video_digest', 'Video Digest'), ('product_image', 'Product Image'), ('person_image', 'Person Image'), ('model_triview', 'Model Triview'), ('scene_image', 'Scene Image'), ('storyboard', 'Storyboard'), ('video_segment', 'Video Segment'), ('voiceover', 'Voiceover'), ('export', 'Export'), ('free_video', 'Free Video')], max_length=48), + ), + ] diff --git a/core/backend/apps/ai/models.py b/core/backend/apps/ai/models.py index d4fa8e6..4c282b2 100644 --- a/core/backend/apps/ai/models.py +++ b/core/backend/apps/ai/models.py @@ -92,6 +92,7 @@ class AITask(TeamOwnedModel): SCRIPT_GENERATION = "script_generation", "Script Generation" SCRIPT_OPTIMIZATION = "script_optimization", "Script Optimization" ENTITY_EXTRACTION = "entity_extraction", "Entity Extraction" + VIDEO_DIGEST = "video_digest", "Video Digest" # 上传视频提炼:参考视频 → 分镜稿 PRODUCT_IMAGE = "product_image", "Product Image" PERSON_IMAGE = "person_image", "Person Image" MODEL_TRIVIEW = "model_triview", "Model Triview" diff --git a/core/backend/apps/ai/script_agent.py b/core/backend/apps/ai/script_agent.py index 925d5e4..ec2a599 100644 --- a/core/backend/apps/ai/script_agent.py +++ b/core/backend/apps/ai/script_agent.py @@ -19,6 +19,7 @@ SSE 事件(每帧 `data: {json}\n\n`,json 带 type): from __future__ import annotations import json +import math import re from decimal import Decimal from functools import lru_cache @@ -34,7 +35,70 @@ from apps.billing.services.ledger import charge_reserved_credit, release_credit VALID_TONES = ["种草", "测评", "剧情", "痛点"] VALID_ROLES = ["钩子", "痛点", "卖点", "CTA"] VALID_ENTITY_TYPES = ["character", "scene", "product"] -DURATION_TIERS = [15, 30, 60, 90] + +# 时长:总时长 5–60 秒按 5 秒步进;单镜 4–15 秒(15 是出片模型硬上限,越界下游直接拒片)。 +TOTAL_DURATION_MIN = 5 +TOTAL_DURATION_MAX = 60 +TOTAL_DURATION_STEP = 5 +SEGMENT_DURATION_MIN = 4 +SEGMENT_DURATION_MAX = 15 +DEFAULT_TOTAL_DURATION = 30 + +# 表现形式 × 视频结构(二期)。key 用 ASCII 找套路文件,label 是给模型和用户看的中文。 +PRESENTATION_FORMATS: dict[str, str] = {"oral": "口播", "drama": "短剧", "vlog": "Vlog"} +VIDEO_STRUCTURES: dict[str, str] = { + "pain": "痛点解决", + "contrast": "前后对比", + "review": "测评验证", + "scene": "场景种草", +} +DEFAULT_PRESENTATION_FORMAT = "oral" +DEFAULT_VIDEO_STRUCTURE = "pain" + +# 唯一禁用组合:短剧 × 测评验证。演出来的实测没有可信度,详见 playbooks/combo-matrix.md。 +FORBIDDEN_COMBOS: set[tuple[str, str]] = {("drama", "review")} + +# 表现形式推荐的单镜节奏(秒)。镜数 ≈ 总时长 / 该值,再夹到 4–15 秒的合法区间。 +FORMAT_SHOT_PACE: dict[str, int] = {"oral": 12, "drama": 8, "vlog": 7} +# 表现形式推荐的默认总时长:口播短平快,短剧要装下三幕,Vlog 要铺氛围。 +FORMAT_DEFAULT_DURATION: dict[str, int] = {"oral": 30, "drama": 45, "vlog": 30} +# 各结构能压到的最短总时长(低于此值证据/氛围不成立),见 playbooks/combo-matrix.md。 +STRUCTURE_MIN_DURATION: dict[str, int] = {"pain": 15, "contrast": 10, "review": 20, "scene": 20} + +# 可懂语速上限 3.5 字/秒 —— 旁白字数按这一镜自己的秒数算,不再全场 55 字一刀切。 +NARRATION_CHARS_PER_SECOND = 3.5 +NARRATION_CHARS_HARD_CAP = 55 + + +_FORMAT_KEY_BY_LABEL = {label: key for key, label in PRESENTATION_FORMATS.items()} +_STRUCTURE_KEY_BY_LABEL = {label: key for key, label in VIDEO_STRUCTURES.items()} + + +def combo_keys(value_format, value_structure) -> tuple[str, str]: + """把「中文标签或 ASCII key」都归一成 key。落库存的是中文,请求传的是 key,两边都要认。""" + fmt = _FORMAT_KEY_BY_LABEL.get(value_format, value_format) + structure = _STRUCTURE_KEY_BY_LABEL.get(value_structure, value_structure) + return coerce_combo(fmt, structure) + + +def allowed_structures(fmt: str) -> list[str]: + """某表现形式下可选的视频结构 key(1.8 组合联动:换表现形式,结构列表跟着变)。""" + fmt = fmt if fmt in PRESENTATION_FORMATS else DEFAULT_PRESENTATION_FORMAT + return [key for key in VIDEO_STRUCTURES if (fmt, key) not in FORBIDDEN_COMBOS] + + +def coerce_combo(fmt: str | None, structure: str | None) -> tuple[str, str]: + """把任意输入夹成一组合法的(表现形式, 视频结构)。禁用组合回落到该形式的第一个合法结构。""" + fmt = fmt if fmt in PRESENTATION_FORMATS else DEFAULT_PRESENTATION_FORMAT + structure = structure if structure in VIDEO_STRUCTURES else DEFAULT_VIDEO_STRUCTURE + if (fmt, structure) in FORBIDDEN_COMBOS: + structure = allowed_structures(fmt)[0] + return fmt, structure + + +def narration_limit(duration: int) -> int: + """这一镜旁白的字数上限:秒数 × 3.5,且不超过硬上限 55。""" + return max(1, min(NARRATION_CHARS_HARD_CAP, int(duration * NARRATION_CHARS_PER_SECOND))) # --------------------------------------------------------------------------- # @@ -53,9 +117,18 @@ def _skill_dir() -> Path: return base / "skills" / "ecommerce-video-script" +def _read_ref(path: Path, label: str) -> str: + if not path.exists(): + return "" + return f"\n\n===== {label} =====\n\n{path.read_text(encoding='utf-8')}" + + @lru_cache(maxsize=1) -def load_ecommerce_skill() -> str: - """读取 SKILL.md + 全部 references 拼成系统提示词(领域知识)。缺文件不致命,尽量给。""" +def _load_skill_base() -> str: + """SKILL.md + references 根目录下的通用资料(方法论/钩子库/品类/平台/自检),每次都要。 + + playbooks/ 是子目录,glob("*.md") 不会递归到,套路由 load_ecommerce_skill 按组合单独挑。 + """ skill_dir = _skill_dir() parts: list[str] = [] main = skill_dir / "SKILL.md" @@ -64,11 +137,33 @@ def load_ecommerce_skill() -> str: ref_dir = skill_dir / "references" if ref_dir.exists(): for ref in sorted(ref_dir.glob("*.md")): - parts.append(f"\n\n===== references/{ref.name} =====\n\n{ref.read_text(encoding='utf-8')}") - if not parts: + parts.append(_read_ref(ref, f"references/{ref.name}")) + return "".join(parts) + + +@lru_cache(maxsize=32) +def load_ecommerce_skill( + presentation_format: str = DEFAULT_PRESENTATION_FORMAT, + video_structure: str = DEFAULT_VIDEO_STRUCTURE, +) -> str: + """通用资料 + 组合矩阵 + **只挑被选中的那一份表现形式和那一份视频结构**。 + + 套路全量灌进去会让系统提示词翻倍(每份 2-3K 字),而且模型会在 11 套互相矛盾的 + 镜头语言里挑花眼。只给当前这一组,提示词更短、约束更硬。 + """ + fmt, structure = coerce_combo(presentation_format, video_structure) + playbooks = _skill_dir() / "references" / "playbooks" + parts = [ + _load_skill_base(), + _read_ref(playbooks / "combo-matrix.md", "references/playbooks/combo-matrix.md"), + _read_ref(playbooks / f"format-{fmt}.md", f"references/playbooks/format-{fmt}.md"), + _read_ref(playbooks / f"structure-{structure}.md", f"references/playbooks/structure-{structure}.md"), + ] + joined = "".join(parts) + if not joined.strip(): # 兜底:skill 文件缺失也能退化生成(交接文档会提示补 skills 目录) return "你是电商带货短视频脚本生成 agent,输出结构化 ScriptDraft JSON。" - return "".join(parts) + return joined # 运行时输出协议:优先级高于 skill 里的「只输出 JSON / 不展示思考」,只为流式体感放开一句前言。 @@ -119,12 +214,25 @@ def build_agent_messages( base_draft: dict | None, aspect_ratio: str, total_duration: int, + presentation_format: str = DEFAULT_PRESENTATION_FORMAT, + video_structure: str = DEFAULT_VIDEO_STRUCTURE, target_index: int | None = None, ) -> list[dict[str, str]]: - system = load_ecommerce_skill() + _OUTPUT_PROTOCOL + fmt, structure = coerce_combo(presentation_format, video_structure) + total = coerce_total_duration(total_duration) + system = load_ecommerce_skill(fmt, structure) + _OUTPUT_PROTOCOL + + # 给一组建议时长(不是硬性),模型可以按内容调整,只要每镜 4–15 秒且加总不变。 + suggested = plan_segment_durations(total, fmt) + pace_hint = "+".join(str(d) for d in suggested) head = ( f"【画幅】{aspect_ratio}\n" - f"【总时长】{total_duration} 秒(每 15 秒一镜,共 {total_duration // 15} 镜)\n" + f"【表现形式】{PRESENTATION_FORMATS[fmt]}(套路见 playbooks/format-{fmt}.md,已加载)\n" + f"【视频结构】{VIDEO_STRUCTURES[structure]}(套路见 playbooks/structure-{structure}.md,已加载)\n" + f"【总时长】{total} 秒\n" + f"【分镜时长】单镜 4–15 秒,可以不等长;各镜相加必须精确等于 {total} 秒。\n" + f" 建议切成 {len(suggested)} 镜({pace_hint}),这是按「{PRESENTATION_FORMATS[fmt]}」的节奏算的;\n" + f" 你可以按内容调整镜数与每镜长短(该长的给足、该短的压短),但必须守住上面两条硬约束。\n" f"【商品信息】\n{_product_context(project, selling_point_ids)}" ) if mode == "revise" and base_draft and target_index is not None: @@ -154,7 +262,8 @@ def build_agent_messages( ) else: user = ( - "【任务】全自动(模式①):仅凭商品与前置条件,自动定档/选 tone/造 entity/填黄金结构。\n" + "【任务】全自动(模式①):仅凭商品与前置条件,按指定的表现形式与视频结构套路" + "自动定镜数/选 tone/造 entity/填结构骨架。\n" f"{head}\n\n" "请按技能流程一次性产出 ScriptDraft。" ) @@ -217,14 +326,87 @@ def _extract_json(text: str) -> str | None: return _balanced_object(text) -def _nearest_duration(value) -> int: +def coerce_total_duration(value) -> int: + """总时长夹到 5–60 秒、5 秒步进。空值/0/非法输入一律回落默认 30。""" + if value in (None, "", 0): + return DEFAULT_TOTAL_DURATION try: value = int(value) except (TypeError, ValueError): - return 60 - if value in DURATION_TIERS: - return value - return min(DURATION_TIERS, key=lambda t: abs(t - value)) + return DEFAULT_TOTAL_DURATION + if value <= 0: + return DEFAULT_TOTAL_DURATION + value = max(TOTAL_DURATION_MIN, min(TOTAL_DURATION_MAX, value)) + stepped = int(round(value / TOTAL_DURATION_STEP) * TOTAL_DURATION_STEP) + return max(TOTAL_DURATION_MIN, min(TOTAL_DURATION_MAX, stepped)) + + +def plan_segment_durations(total_duration: int, presentation_format: str) -> list[int]: + """把总时长切成每镜 4–15 秒、加总精确等于总时长的一组时长。 + + 镜数按表现形式的推荐节奏定(口播 12s/镜、短剧 8s/镜、Vlog 7s/镜),再夹进 + ceil(total/15) ~ total//4 的合法区间。余数摊到前面几镜,所以镜与镜之间最多差 1 秒—— + 这只是**兜底**,模型自己给的不等长时长只要合法就照用。 + """ + total = coerce_total_duration(total_duration) + fmt = presentation_format if presentation_format in FORMAT_SHOT_PACE else DEFAULT_PRESENTATION_FORMAT + count_min = math.ceil(total / SEGMENT_DURATION_MAX) + count_max = max(count_min, total // SEGMENT_DURATION_MIN) + count = max(1, round(total / FORMAT_SHOT_PACE[fmt])) + count = max(count_min, min(count_max, count)) + base, remainder = divmod(total, count) + return [base + 1 if i < remainder else base for i in range(count)] + + +def plan_roles(count: int) -> list[str]: + """镜数 → role 序列。通用规则:首钩子、次痛点、末 CTA,中间全是卖点。""" + if count <= 1: + return ["钩子"] + if count == 2: + return ["钩子", "卖点"] + if count == 3: + return ["钩子", "卖点", "CTA"] + return ["钩子", "痛点"] + ["卖点"] * (count - 3) + ["CTA"] + + +def _fit_segment_durations(raw: list, total: int, presentation_format: str) -> list[int]: + """采纳模型给的每镜时长(允许不等长),非法就修;修不动就整组回落到 plan_segment_durations。 + + 合法定义:每镜 4–15 秒的整数,且加总 == 总时长。模型很容易把总数算错一两秒, + 所以先夹单镜范围,再把差额摊到还有余量的镜上,尽量保住模型的节奏意图。 + """ + if not raw: + return plan_segment_durations(total, presentation_format) + + durations: list[int] = [] + for value in raw: + try: + seconds = int(value) + except (TypeError, ValueError): + seconds = 0 + durations.append(max(SEGMENT_DURATION_MIN, min(SEGMENT_DURATION_MAX, seconds or SEGMENT_DURATION_MIN))) + + # 镜数本身就装不下总时长(太少会超 15s/镜,太多会低于 4s/镜)→ 模型节奏不可用,整组重排。 + count = len(durations) + if not (count * SEGMENT_DURATION_MIN <= total <= count * SEGMENT_DURATION_MAX): + return plan_segment_durations(total, presentation_format) + + diff = total - sum(durations) + while diff != 0: + step = 1 if diff > 0 else -1 + # 每轮只给「还有余量」的镜加/减 1 秒,均匀铺开,避免把某一镜顶到边界 + movable = [ + i for i, d in enumerate(durations) + if (step > 0 and d < SEGMENT_DURATION_MAX) or (step < 0 and d > SEGMENT_DURATION_MIN) + ] + if not movable: + return plan_segment_durations(total, presentation_format) + for i in movable: + if diff == 0: + break + durations[i] += step + diff -= step + return durations # 模型每次生成都可能换字段名(scene/screenDescription/visual…、dialogue/lines/caption…), @@ -306,7 +488,14 @@ def _resolve_segments(draft: dict) -> list: return best -def normalize_draft(raw_text: str, *, aspect_ratio: str, total_duration: int) -> dict: +def normalize_draft( + raw_text: str, + *, + aspect_ratio: str, + total_duration: int, + presentation_format: str = DEFAULT_PRESENTATION_FORMAT, + video_structure: str = DEFAULT_VIDEO_STRUCTURE, +) -> dict: """把模型输出抽成 JSON 并按铁律1契约规范化。宽容:小问题就地修,不轻易抛错。""" blob = _extract_json(raw_text) if not blob: @@ -332,10 +521,12 @@ def normalize_draft(raw_text: str, *, aspect_ratio: str, total_duration: int) -> draft["segments"] = _resolve_segments(draft) draft["aspect_ratio"] = (draft.get("aspect_ratio") or aspect_ratio or "9:16").strip() - dur = _nearest_duration(draft.get("total_duration") or total_duration) + # 总时长以「请求参数」为准:模型经常把它算错,而下游出片/计价都按这个数走。 + dur = coerce_total_duration(total_duration) draft["total_duration"] = dur - seg_count = max(1, dur // 15) - draft["segment_count"] = seg_count + fmt, structure = coerce_combo(presentation_format, video_structure) + draft["presentation_format"] = PRESENTATION_FORMATS[fmt] + draft["video_structure"] = VIDEO_STRUCTURES[structure] tone = (draft.get("tone") or "").strip() draft["tone"] = tone if tone in VALID_TONES else "种草" draft["hook"] = (draft.get("hook") or "").strip() @@ -367,15 +558,21 @@ def normalize_draft(raw_text: str, *, aspect_ratio: str, total_duration: int) -> draft["entities"] = norm_entities valid_ids = {e["id"] for e in norm_entities} - # segments 规范化:对齐镜数,role 枚举,引用合法 + # segments 规范化:镜数交给模型(只夹进合法区间),role 枚举,引用合法 segments = draft.get("segments") if isinstance(draft.get("segments"), list) else [] + # 镜数上下限由「单镜 4–15 秒」倒推:少于 count_min 会有镜超 15 秒,多于 count_max 会有镜不足 4 秒。 + count_min = math.ceil(dur / SEGMENT_DURATION_MAX) + count_max = max(count_min, dur // SEGMENT_DURATION_MIN) + segments = segments[:count_max] + seg_count = max(count_min, len(segments)) + role_plan = plan_roles(seg_count) norm_segments: list[dict] = [] - for i, seg in enumerate(segments[:seg_count]): + for i, seg in enumerate(segments): if not isinstance(seg, dict): seg = {} role = (seg.get("role") or "").strip() if role not in VALID_ROLES: - role = VALID_ROLES[min(i, len(VALID_ROLES) - 1)] + role = role_plan[i] speaker = seg.get("speaker") speaker = speaker if (speaker in valid_ids) else None refs = [r for r in (seg.get("entity_refs") or []) if r in valid_ids] @@ -406,7 +603,7 @@ def normalize_draft(raw_text: str, *, aspect_ratio: str, total_duration: int) -> norm_segments.append( { "index": i, - "duration": 15, + "duration": seg.get("duration"), # 先原样收着,等镜数定了再统一夹进 4–15 秒并配平总时长 "role": role, "narration": narration, "speaker": speaker, @@ -416,14 +613,14 @@ def normalize_draft(raw_text: str, *, aspect_ratio: str, total_duration: int) -> "dialogue": dialogue, } ) - # 不足镜数则补占位镜(极少发生,避免下游镜数对不上) + # 不足下限则补占位镜(极少发生,避免出现超过 15 秒的镜导致下游拒片) while len(norm_segments) < seg_count: i = len(norm_segments) norm_segments.append( { "index": i, - "duration": 15, - "role": VALID_ROLES[min(i, len(VALID_ROLES) - 1)], + "duration": None, + "role": role_plan[i], "narration": "", "speaker": None, "visual": "", @@ -434,11 +631,26 @@ def normalize_draft(raw_text: str, *, aspect_ratio: str, total_duration: int) -> ) if not norm_segments: raise ValueError("脚本没有任何分镜") + + # 每镜时长:采纳模型的不等长意图,非法就修,修不动整组回落到按表现形式节奏均切。 + fitted = _fit_segment_durations([s["duration"] for s in norm_segments], dur, fmt) + for seg, seconds in zip(norm_segments, fitted): + seg["duration"] = seconds + draft["segments"] = norm_segments + draft["segment_count"] = len(norm_segments) return draft -def _merge_single_segment(base: dict, new: dict, idx: int, aspect_ratio: str, total_duration: int) -> dict: +def _merge_single_segment( + base: dict, + new: dict, + idx: int, + aspect_ratio: str, + total_duration: int, + presentation_format: str = DEFAULT_PRESENTATION_FORMAT, + video_structure: str = DEFAULT_VIDEO_STRUCTURE, +) -> dict: """精准改一镜:以基准稿为底,只用新稿的第 idx 镜替换,其余镜逐字保持;合并新稿引入的新 entity(对白可能加角色)。再整体规范化。""" merged = json.loads(json.dumps(base)) # 深拷贝 base_ids = {e.get("id") for e in merged.get("entities", []) if isinstance(e, dict)} @@ -459,7 +671,13 @@ def _merge_single_segment(base: dict, new: dict, idx: int, aspect_ratio: str, to target["index"] = idx segs[idx] = target merged["segments"] = segs - return normalize_draft(json.dumps(merged, ensure_ascii=False), aspect_ratio=aspect_ratio, total_duration=total_duration) + return normalize_draft( + json.dumps(merged, ensure_ascii=False), + aspect_ratio=aspect_ratio, + total_duration=total_duration, + presentation_format=presentation_format, + video_structure=video_structure, + ) # --------------------------------------------------------------------------- # @@ -496,14 +714,17 @@ def persist_script_draft(*, project, user, task, draft: dict, source: str): task=task, title=(draft.get("hook") or "AI 脚本")[:128], content=json.dumps(draft, ensure_ascii=False, indent=2), - source=source if source in ("ai", "theme", "manual", "revise") else "ai", + source=source if source in ("ai", "theme", "manual", "video", "revise") else "ai", is_adopted=False, metadata={ "hook": draft.get("hook", ""), "tone": draft.get("tone", ""), "aspect_ratio": draft.get("aspect_ratio", "9:16"), - "total_duration": draft.get("total_duration", 60), - "segment_count": draft.get("segment_count", 4), + "total_duration": draft.get("total_duration", DEFAULT_TOTAL_DURATION), + "segment_count": draft.get("segment_count", len(draft.get("segments") or [])), + # 二期:表现形式 × 视频结构 跟着稿子走,改稿和「保存模板」都要读它 + "presentation_format": draft.get("presentation_format", ""), + "video_structure": draft.get("video_structure", ""), "entities": draft.get("entities", []), }, ) @@ -511,7 +732,7 @@ def persist_script_draft(*, project, user, task, draft: dict, source: str): ScriptSegment.objects.create( script_version=script, sort_order=seg["index"], - duration_seconds=seg.get("duration", 15), + duration_seconds=seg.get("duration") or SEGMENT_DURATION_MAX, narration=seg.get("narration", ""), visual_prompt=seg.get("visual", ""), role=seg.get("role", ""), @@ -570,15 +791,20 @@ def stream_script_agent( selling_point_ids: list[str] | None = None, base_version_id: str | None = None, aspect_ratio: str = "9:16", - total_duration: int = 60, + total_duration: int = DEFAULT_TOTAL_DURATION, + presentation_format: str = DEFAULT_PRESENTATION_FORMAT, + video_structure: str = DEFAULT_VIDEO_STRUCTURE, target_index: int | None = None, + entry_source: str = "", ): """生成 SSE 帧字符串的同步生成器,供 StreamingHttpResponse 包裹。 target_index 非空 = 精准只改第 N 镜(读全脚本上下文,后端强制保留其余镜原样)。""" from apps.ai.services import create_ai_task, stream_routed_text_request - yield _sse({"type": "tool", "id": "skill", "label": "加载电商脚本技能", "status": "running"}) - skill_loaded = bool(load_ecommerce_skill()) + fmt, structure = coerce_combo(presentation_format, video_structure) + + yield _sse({"type": "tool", "id": "skill", "label": f"加载套路:{PRESENTATION_FORMATS[fmt]} · {VIDEO_STRUCTURES[structure]}", "status": "running"}) + skill_loaded = bool(load_ecommerce_skill(fmt, structure)) yield _sse({"type": "tool", "id": "skill", "status": "done" if skill_loaded else "error"}) yield _sse({"type": "tool", "id": "analyze", "label": f"分析商品:{project.product.title}", "status": "running"}) @@ -587,8 +813,10 @@ def stream_script_agent( base_draft = _load_base_draft(project, base_version_id) if base_draft is None: target_index = None # 没有基准稿就退回整版生成,单镜改无从谈起 - # 改稿以基准稿的时长/镜数为准,避免请求侧默认值(前端可能硬编码 60)把 90s/6镜稿的尾镜挤掉 - effective_duration = (base_draft.get("total_duration") if base_draft else None) or total_duration + # 改稿以基准稿的时长/镜数为准,避免请求侧默认值把长稿的尾镜挤掉 + effective_duration = coerce_total_duration( + (base_draft.get("total_duration") if base_draft else None) or total_duration + ) # 精准改一镜:镜号越界直接报错返回,绝不建任务/扣费(避免计费空转的静默 no-op) if target_index is not None and base_draft is not None: seg_n = len(base_draft.get("segments", [])) @@ -602,7 +830,9 @@ def stream_script_agent( selling_point_ids=selling_point_ids, base_draft=base_draft, aspect_ratio=aspect_ratio, - total_duration=effective_duration, # 改稿用基准稿时长,prompt head 才不会误导模型镜数(否则模型按60s只出4镜) + total_duration=effective_duration, # 改稿用基准稿时长,prompt head 才不会误导模型镜数 + presentation_format=fmt, + video_structure=structure, target_index=target_index, ) yield _sse({"type": "tool", "id": "analyze", "status": "done"}) @@ -648,6 +878,8 @@ def stream_script_agent( raw_text, aspect_ratio=aspect_ratio, total_duration=effective_duration, + presentation_format=fmt, + video_structure=structure, ) if target_index is not None and base_draft: return _merge_single_segment( @@ -656,6 +888,8 @@ def stream_script_agent( target_index, aspect_ratio, effective_duration, + fmt, + structure, ) return candidate @@ -735,7 +969,14 @@ def stream_script_agent( task.completed_at = timezone.now() task.save(update_fields=["status", "response_payload", "actual_cost", "completed_at", "updated_at"]) charge_reserved_credit(reservation=reservation, actual_amount=task.actual_cost) - source = "revise" if mode == "revise" else ("theme" if mode == "theme" else "ai") + # 三个入口(辅助生成 / 上传脚本 / 上传视频提炼)都走 mode=auto,只有 entry_source + # 分得清是哪个来的 —— 脚本卡的「来源」徽标靠它,别一律记成 ai。 + if mode == "revise": + source = "revise" + elif mode == "theme": + source = "theme" + else: + source = entry_source if entry_source in {"manual", "video"} else "ai" script = persist_script_draft(project=project, user=user, task=task, draft=draft, source=source) settled = True # charge 已提交 except Exception as exc: # noqa: BLE001 — 落库失败:atomic 已回滚 charge,补释放预留 @@ -819,20 +1060,24 @@ def _load_base_draft(project, base_version_id: str) -> dict | None: def _draft_from_version(version) -> dict: """从 ScriptVersion 的 DB 行(segments + metadata)重建 ScriptDraft —— 比解析可能已 stale 的 content 可靠 - (用户增删/改镜后 content 不一定同步)。total_duration 按真实镜数算,避免 normalize 按 stale 值截/补镜。""" + (用户增删/改镜后 content 不一定同步)。total_duration 按各镜真实秒数加总,避免 normalize 按 stale 值截/补镜。""" meta = version.metadata or {} segs = list(version.segments.order_by("sort_order")) + # 镜可以不等长了,总时长必须按实际相加(旧写法 15×镜数 会在不等长稿上算出错误总时长) + actual_total = sum(s.duration_seconds or SEGMENT_DURATION_MAX for s in segs) return { "hook": meta.get("hook", ""), "tone": meta.get("tone", ""), + "presentation_format": meta.get("presentation_format", ""), + "video_structure": meta.get("video_structure", ""), "aspect_ratio": meta.get("aspect_ratio", "9:16"), - "total_duration": max(int(meta.get("total_duration") or 0), 15 * len(segs)) or 60, + "total_duration": actual_total or coerce_total_duration(meta.get("total_duration")), "segment_count": len(segs), "entities": meta.get("entities", []), "segments": [ { "index": s.sort_order, - "duration": s.duration_seconds or 15, + "duration": s.duration_seconds or SEGMENT_DURATION_MAX, "role": s.role or "", "narration": s.narration or "", "speaker": s.speaker or None, @@ -860,7 +1105,9 @@ def regenerate_segment_via_agent(*, project, user, model_config: ModelConfig, se seg_n = len(base_draft.get("segments") or []) if not (0 <= target_index < seg_n): raise ValueError(f"镜号越界:第 {target_index + 1} 镜(共 {seg_n} 镜)") - total_duration = base_draft["total_duration"] # 已按真实镜数算,normalize 不会截掉用户增删后的镜 + total_duration = base_draft["total_duration"] # 已按各镜真实秒数加总,normalize 不会截掉用户增删后的镜 + # 改一镜要沿用原稿的套路,否则重写出来的那一镜镜头语言会跟其余镜打架 + fmt, structure = combo_keys(base_draft.get("presentation_format"), base_draft.get("video_structure")) messages = build_agent_messages( project=project, @@ -870,6 +1117,8 @@ def regenerate_segment_via_agent(*, project, user, model_config: ModelConfig, se base_draft=base_draft, aspect_ratio=aspect_ratio, total_duration=total_duration, + presentation_format=fmt, + video_structure=structure, target_index=target_index, ) task = create_ai_task( @@ -897,8 +1146,16 @@ def regenerate_segment_via_agent(*, project, user, model_config: ModelConfig, se task.submitted_at = timezone.now() task.save(update_fields=["status", "submitted_at", "updated_at"]) def validate_segment_text(raw_text: str) -> dict: - candidate = normalize_draft(raw_text, aspect_ratio=aspect_ratio, total_duration=total_duration) - return _merge_single_segment(base_draft, candidate, target_index, aspect_ratio, total_duration) + candidate = normalize_draft( + raw_text, + aspect_ratio=aspect_ratio, + total_duration=total_duration, + presentation_format=fmt, + video_structure=structure, + ) + return _merge_single_segment( + base_draft, candidate, target_index, aspect_ratio, total_duration, fmt, structure + ) routed = execute_routed_text_request( task=task, diff --git a/core/backend/apps/ai/test_free_video_asset_ref.py b/core/backend/apps/ai/test_free_video_asset_ref.py new file mode 100644 index 0000000..fa897dc --- /dev/null +++ b/core/backend/apps/ai/test_free_video_asset_ref.py @@ -0,0 +1,150 @@ +"""自由创作 @引用三库(模块4 · 4.1/4.2)单测:平台资产解析 + 审核闸。 + +运行:DB_ENGINE=sqlite python manage.py test apps.ai.test_free_video_asset_ref --settings=airshelf.settings.test + +关键不变量:引用是否放行只看 Asset.source + review_status,**不看**资产在不在库里 —— +按库免审等于把审核架空(用户传图进库再引用出去就绕过了人像审核)。 +""" +from unittest.mock import patch + +from django.test import TestCase + +from apps.accounts.models import Team, User +from apps.ai.free_video import build_content_items +from apps.assets.models import Asset, AssetFile +from apps.assets.review import reference_review_state + + +def _asset(team, *, source, review_status="", review_remote_id="", category=Asset.Category.UPLOAD): + asset = Asset.objects.create( + team=team, + name="素材", + asset_type=Asset.Type.IMAGE, + source=source, + category=category, + review_status=review_status, + review_remote_id=review_remote_id, + ) + AssetFile.objects.create( + asset=asset, + object_key="k/1.png", + bucket="b", + content_type="image/png", + size_bytes=1, + preview_url="http://tos/1.png", + is_primary=True, + ) + return asset + + +class ReferenceReviewStateTests(TestCase): + """审核判定四态。审核服务开着时才生效,关着一律放行(不能拿没配置的机制拦人)。""" + + def setUp(self): + self.user = User.objects.create_user(username="refowner", password="p") + self.team = Team.objects.create(name="REF", owner=self.user) + patch("apps.assets.assets_client.is_enabled", return_value=True).start() + self.addCleanup(patch.stopall) + + def test_platform_generated_is_exempt(self): + asset = _asset(self.team, source=Asset.Source.AI_GENERATED) + self.assertEqual(reference_review_state(asset), "allowed") + + def test_upload_needs_active(self): + self.assertEqual(reference_review_state(_asset(self.team, source=Asset.Source.UPLOAD)), "unsubmitted") + self.assertEqual( + reference_review_state(_asset(self.team, source=Asset.Source.UPLOAD, review_status="processing")), + "processing", + ) + self.assertEqual( + reference_review_state(_asset(self.team, source=Asset.Source.UPLOAD, review_status="failed")), + "failed", + ) + self.assertEqual( + reference_review_state(_asset(self.team, source=Asset.Source.UPLOAD, review_status="active")), + "allowed", + ) + + def test_in_library_alone_does_not_exempt(self): + """核心:进了资产库 ≠ 审过。只要是上传来源,没审过就不能引用。""" + asset = _asset(self.team, source=Asset.Source.UPLOAD) + asset.in_library = True + asset.save(update_fields=["in_library"]) + self.assertEqual(reference_review_state(asset), "unsubmitted") + + def test_review_disabled_lets_everything_through(self): + patch("apps.assets.assets_client.is_enabled", return_value=False).start() + self.assertEqual(reference_review_state(_asset(self.team, source=Asset.Source.UPLOAD)), "allowed") + + +class AssetReferenceBuildTests(TestCase): + """source=asset 分支:URL 解析 + 闸门拦截 + @label 映射。""" + + def setUp(self): + self.user = User.objects.create_user(username="refbuild", password="p") + self.team = Team.objects.create(name="REFB", owner=self.user) + patch("apps.assets.assets_client.is_enabled", return_value=True).start() + self.addCleanup(patch.stopall) + + def _build(self, asset, label="模特A"): + return build_content_items( + team=self.team, + prompt=f"@{label} 走过来", + mode="universal", + references=[{"type": "image", "label": label, "asset_id": str(asset.id), "source": "asset"}], + ) + + def test_platform_asset_resolves_to_preview_url(self): + asset = _asset(self.team, source=Asset.Source.AI_GENERATED) + built = self._build(asset) + self.assertEqual(built["image_n"], 1) + self.assertEqual(built["content_items"][0]["image_url"]["url"], "http://tos/1.png") + self.assertEqual(built["api_prompt"], "图片1 走过来") + + def test_registered_asset_uses_volcano_asset_scheme(self): + """已登记火山素材库的走 asset://,写实人脸传直链会被拒。""" + asset = _asset( + self.team, + source=Asset.Source.AI_GENERATED, + category=Asset.Category.PERSON, + review_status="active", + review_remote_id="asset-abc", + ) + built = self._build(asset) + self.assertEqual(built["content_items"][0]["image_url"]["url"], "asset://asset-abc") + + def test_unreviewed_upload_is_blocked_and_submitted(self): + asset = _asset(self.team, source=Asset.Source.UPLOAD) + with patch("apps.assets.review.submit_asset_for_review", return_value=True) as submit: + with self.assertRaises(ValueError) as ctx: + self._build(asset) + self.assertIn("已提交审核", str(ctx.exception)) + submit.assert_called_once() + self.assertTrue(submit.call_args.kwargs["force"]) # 上传素材不看类目白名单,一律登记 + + def test_failed_review_is_blocked(self): + asset = _asset(self.team, source=Asset.Source.UPLOAD, review_status="failed") + with self.assertRaises(ValueError) as ctx: + self._build(asset) + self.assertIn("未通过审核", str(ctx.exception)) + + def test_other_team_asset_is_not_visible(self): + stranger = User.objects.create_user(username="stranger", password="p") + other = Team.objects.create(name="OTHER", owner=stranger) + asset = _asset(other, source=Asset.Source.AI_GENERATED) + with self.assertRaises(ValueError) as ctx: + self._build(asset) + self.assertIn("不存在", str(ctx.exception)) + + def test_keyframe_rejects_non_image(self): + asset = _asset(self.team, source=Asset.Source.AI_GENERATED) + asset.asset_type = Asset.Type.VIDEO + asset.save(update_fields=["asset_type"]) + with self.assertRaises(ValueError) as ctx: + build_content_items( + team=self.team, + prompt="动起来", + mode="keyframe", + references=[{"type": "video", "asset_id": str(asset.id), "source": "asset", "role": "first_frame"}], + ) + self.assertIn("首尾帧模式仅支持图片素材", str(ctx.exception)) diff --git a/core/backend/apps/ai/test_script_duration_combo.py b/core/backend/apps/ai/test_script_duration_combo.py new file mode 100644 index 0000000..868a3b3 --- /dev/null +++ b/core/backend/apps/ai/test_script_duration_combo.py @@ -0,0 +1,162 @@ +"""二期第2段:时长自由化 + 表现形式 × 视频结构 的纯函数单测(不碰 DB / 不调模型)。 + +覆盖三件容易悄悄坏掉的事: +1. 切镜结果必须「每镜 4–15 秒」且「加总精确等于总时长」—— 越界下游出片会直接拒。 +2. 模型给的每镜时长可能是错的(加总对不上/单镜越界),后端必须能修回来而不是原样落库。 +3. 套路必须按组合**选择性**加载,全量灌进去会让系统提示词翻倍且互相打架。 +""" +from django.test import SimpleTestCase + +from apps.ai.script_agent import ( + DEFAULT_TOTAL_DURATION, + PRESENTATION_FORMATS, + SEGMENT_DURATION_MAX, + SEGMENT_DURATION_MIN, + TOTAL_DURATION_MAX, + TOTAL_DURATION_MIN, + VIDEO_STRUCTURES, + _fit_segment_durations, + allowed_structures, + coerce_combo, + coerce_total_duration, + combo_keys, + load_ecommerce_skill, + narration_limit, + plan_roles, + plan_segment_durations, +) + +ALL_TOTALS = list(range(TOTAL_DURATION_MIN, TOTAL_DURATION_MAX + 1, 5)) + + +class TotalDurationCoercionTests(SimpleTestCase): + def test_empty_and_invalid_fall_back_to_default(self): + for raw in (None, "", 0, -5, "abc", object()): + self.assertEqual(coerce_total_duration(raw), DEFAULT_TOTAL_DURATION, raw) + + def test_clamped_into_range_and_snapped_to_step(self): + self.assertEqual(coerce_total_duration(3), TOTAL_DURATION_MIN) + self.assertEqual(coerce_total_duration(999), TOTAL_DURATION_MAX) + self.assertEqual(coerce_total_duration(7), 5) + self.assertEqual(coerce_total_duration(8), 10) + self.assertEqual(coerce_total_duration(45), 45) + + +class SegmentPlanningTests(SimpleTestCase): + def test_every_total_and_format_yields_legal_shots(self): + for total in ALL_TOTALS: + for fmt in PRESENTATION_FORMATS: + with self.subTest(total=total, fmt=fmt): + durations = plan_segment_durations(total, fmt) + self.assertEqual(sum(durations), total) + for seconds in durations: + self.assertGreaterEqual(seconds, SEGMENT_DURATION_MIN) + self.assertLessEqual(seconds, SEGMENT_DURATION_MAX) + + def test_faster_format_produces_more_shots(self): + # 同样 60 秒,Vlog(碎片)镜数应多于口播(要把话说完) + self.assertGreater( + len(plan_segment_durations(60, "vlog")), + len(plan_segment_durations(60, "oral")), + ) + + def test_roles_follow_shot_count(self): + self.assertEqual(plan_roles(1), ["钩子"]) + self.assertEqual(plan_roles(2), ["钩子", "卖点"]) + self.assertEqual(plan_roles(3), ["钩子", "卖点", "CTA"]) + self.assertEqual(plan_roles(4), ["钩子", "痛点", "卖点", "CTA"]) + self.assertEqual(plan_roles(6), ["钩子", "痛点", "卖点", "卖点", "卖点", "CTA"]) + + def test_roles_always_open_with_hook_and_close_with_cta(self): + for count in range(3, 13): + plan = plan_roles(count) + self.assertEqual(len(plan), count) + self.assertEqual(plan[0], "钩子") + self.assertEqual(plan[-1], "CTA") + + +class SegmentDurationFittingTests(SimpleTestCase): + def test_legal_uneven_durations_are_kept_as_is(self): + # 模型的不等长节奏意图只要合法就不该被抹平 + self.assertEqual(_fit_segment_durations([12, 8, 10], 30, "oral"), [12, 8, 10]) + + def test_wrong_total_is_repaired(self): + fitted = _fit_segment_durations([15, 15, 15, 15], 30, "oral") + self.assertEqual(sum(fitted), 30) + self.assertTrue(all(SEGMENT_DURATION_MIN <= d <= SEGMENT_DURATION_MAX for d in fitted)) + + def test_out_of_range_shot_is_repaired(self): + fitted = _fit_segment_durations([99, 1], 30, "drama") + self.assertEqual(sum(fitted), 30) + self.assertTrue(all(SEGMENT_DURATION_MIN <= d <= SEGMENT_DURATION_MAX for d in fitted)) + + def test_empty_falls_back_to_planner(self): + self.assertEqual(_fit_segment_durations([], 20, "vlog"), plan_segment_durations(20, "vlog")) + + def test_garbage_values_never_escape_the_legal_range(self): + for raw in ([None, None], ["a", "b"], [0, 0, 0], [3, 3], [40, 40, 40]): + for total in (10, 30, 60): + with self.subTest(raw=raw, total=total): + fitted = _fit_segment_durations(list(raw), total, "oral") + self.assertEqual(sum(fitted), total) + for seconds in fitted: + self.assertGreaterEqual(seconds, SEGMENT_DURATION_MIN) + self.assertLessEqual(seconds, SEGMENT_DURATION_MAX) + + +class NarrationLimitTests(SimpleTestCase): + def test_limit_scales_with_shot_length(self): + self.assertEqual(narration_limit(4), 14) + self.assertEqual(narration_limit(8), 28) + self.assertEqual(narration_limit(15), 52) + + def test_never_exceeds_hard_cap(self): + self.assertLessEqual(narration_limit(60), 55) + + +class ComboTests(SimpleTestCase): + def test_drama_cannot_pick_review(self): + # 演出来的实测没有可信度 —— 这是唯一的禁用组合 + self.assertNotIn("review", allowed_structures("drama")) + self.assertEqual(len(allowed_structures("drama")), 3) + + def test_other_formats_allow_everything(self): + for fmt in ("oral", "vlog"): + self.assertEqual(len(allowed_structures(fmt)), len(VIDEO_STRUCTURES)) + + def test_forbidden_combo_falls_back_instead_of_raising(self): + fmt, structure = coerce_combo("drama", "review") + self.assertEqual(fmt, "drama") + self.assertNotEqual(structure, "review") + + def test_unknown_values_fall_back_to_defaults(self): + self.assertEqual(coerce_combo("nope", "nope"), ("oral", "pain")) + + def test_chinese_labels_round_trip_back_to_keys(self): + # 落库存的是中文标签,改稿时要能还原成 key 去挑套路文件 + self.assertEqual(combo_keys("短剧", "痛点解决"), ("drama", "pain")) + self.assertEqual(combo_keys("Vlog", "场景种草"), ("vlog", "scene")) + self.assertEqual(combo_keys("口播", "测评验证"), ("oral", "review")) + + +class SkillLoadingTests(SimpleTestCase): + def test_only_the_selected_playbooks_are_loaded(self): + for fmt in PRESENTATION_FORMATS: + for structure in allowed_structures(fmt): + with self.subTest(fmt=fmt, structure=structure): + text = load_ecommerce_skill(fmt, structure) + self.assertIn(f"format-{fmt}.md", text) + self.assertIn(f"structure-{structure}.md", text) + for other in PRESENTATION_FORMATS: + if other != fmt: + self.assertNotIn(f"format-{other}.md", text) + for other in VIDEO_STRUCTURES: + if other != structure: + self.assertNotIn(f"structure-{other}.md", text) + + def test_combo_matrix_is_always_loaded(self): + self.assertIn("combo-matrix.md", load_ecommerce_skill("oral", "pain")) + + def test_skill_text_is_substantial(self): + # skills 没随镜像打进去时会退化成一句兜底,这里守住「提示词没丢」 + self.assertGreater(len(load_ecommerce_skill("oral", "pain")), 5000) diff --git a/core/backend/apps/ai/test_script_entry_source.py b/core/backend/apps/ai/test_script_entry_source.py new file mode 100644 index 0000000..ffca899 --- /dev/null +++ b/core/backend/apps/ai/test_script_entry_source.py @@ -0,0 +1,118 @@ +"""脚本「来源」要记准是哪个入口生成的。 + +三个入口(脚本辅助生成 / 上传脚本 / 上传视频提炼)都走 mode=auto,后端原先一律记成 ai, +脚本卡上的「来源」徽标因此永远显示「脚本辅助生成」。加了 entry_source 之后锁住这个行为。 +""" + +from __future__ import annotations + +import json +from decimal import Decimal +from unittest.mock import Mock, patch + +from django.test import TransactionTestCase + +from apps.accounts.models import Team, TeamMember, User +from apps.ai.models import ModelConfig, ModelProvider +from apps.ai.script_agent import stream_script_agent +from apps.billing.models import CreditAccount +from apps.products.models import Product +from apps.projects.models import Project, ScriptVersion + + +class ScriptEntrySourceTests(TransactionTestCase): + reset_sequences = True + + def setUp(self): + ModelConfig.objects.filter(capability=ModelConfig.Capability.TEXT).update( + status=ModelConfig.Status.DISABLED + ) + self.user = User.objects.create_user(username="entry-source", password="x") + self.team = Team.objects.create(name="Entry Source", owner=self.user) + TeamMember.objects.create(team=self.team, user=self.user, role=TeamMember.Role.OWNER) + CreditAccount.objects.create(team=self.team, balance=Decimal("1000")) + product = Product.objects.create(team=self.team, created_by=self.user, title="测试商品") + self.project = Project.objects.create( + team=self.team, created_by=self.user, product=product, name="入口来源项目" + ) + provider = ModelProvider.objects.create( + name="entry-source-provider", + display_name="entry-source-provider", + status=ModelProvider.Status.ACTIVE, + metadata={"routing": {"fallback_priority": 20}}, + ) + self.model_config = ModelConfig.objects.create( + provider=provider, + name="entry-source-model", + display_name="entry-source-model", + capability=ModelConfig.Capability.TEXT, + endpoint="chat/completions", + unit_price=Decimal("10"), + status=ModelConfig.Status.ACTIVE, + metadata={ + "routing": {"fallback_on_failure": True, "fallback_candidate": True}, + "capabilities": {"operations": ["chat"], "features": ["streaming", "structured_output"]}, + "pricing": {"base_cost_yuan": "0.50"}, + }, + ) + patch("apps.ai.services.get_text_provider", side_effect=self._provider).start() + self.addCleanup(patch.stopall) + + def _provider(self, _model): + raw = json.dumps( + { + "hook": "开场钩子", + "tone": "自然", + "aspect_ratio": "9:16", + "total_duration": 15, + "segment_count": 1, + "entities": [ + {"id": "c1", "type": "character", "name": "女主", "visual_prompt": "都市女主", "ref_index": 1} + ], + "segments": [ + { + "index": 0, "duration": 15, "role": "钩子", + "narration": "全新脚本口播", "visual": "女主展示商品", + "speaker": "女主", "product_exposure": "展示", + "entity_refs": ["c1"], "dialogue": [], + } + ], + }, + ensure_ascii=False, + ) + provider = Mock() + provider.chat_completion_stream.return_value = iter( + [{"type": "delta", "text": raw}, {"type": "done"}] + ) + return provider + + def _run(self, **kwargs) -> str: + frames = list( + stream_script_agent( + project=self.project, + user=self.user, + model_config=self.model_config, + mode="auto", + user_prompt="生成一版脚本", + aspect_ratio="9:16", + total_duration=15, + **kwargs, + ) + ) + events = [json.loads(frame.removeprefix("data: ").strip()) for frame in frames] + errors = [e for e in events if e.get("type") == "error"] + self.assertFalse(errors, f"生成失败:{events}") + return ScriptVersion.objects.filter(project=self.project).latest("created_at").source + + def test_video_entry_is_recorded_as_video(self): + self.assertEqual(self._run(entry_source="video"), "video") + + def test_upload_script_entry_is_recorded_as_manual(self): + self.assertEqual(self._run(entry_source="manual"), "manual") + + def test_assisted_entry_falls_back_to_ai(self): + self.assertEqual(self._run(entry_source=""), "ai") + + def test_unknown_entry_source_is_not_trusted(self): + """来源直接进 DB 并渲染成徽标,不认的值一律回落 ai,别把请求体原样落库。""" + self.assertEqual(self._run(entry_source="../../etc/passwd"), "ai") diff --git a/core/backend/apps/ai/test_video_digest.py b/core/backend/apps/ai/test_video_digest.py new file mode 100644 index 0000000..8eb9099 --- /dev/null +++ b/core/backend/apps/ai/test_video_digest.py @@ -0,0 +1,142 @@ +"""1.11 上传视频提炼:抽帧规划、上传校验、多模态消息组装、产出判空。 + +不碰真模型:模型侧已在真视频上端到端验证过,这里只锁纯函数与边界。 +""" + +from __future__ import annotations + +import subprocess + +from django.core.files.uploadedfile import SimpleUploadedFile +from django.test import SimpleTestCase + +from apps.ai.video_digest import ( + MAX_DURATION_SECONDS, + MAX_FRAMES, + MAX_UPLOAD_BYTES, + MIN_FRAMES, + SECONDS_PER_FRAME, + VideoDigestError, + VideoFrame, + build_digest_messages, + frames_from_upload, + load_digest_skill, + plan_frame_times, + validate_digest_text, +) + + +class FramePlanTests(SimpleTestCase): + def test_frame_count_scales_with_duration_within_bounds(self): + for duration, expected in [(3, MIN_FRAMES), (10, MIN_FRAMES), (30, 6), (60, 12), (180, MAX_FRAMES)]: + self.assertEqual(len(plan_frame_times(duration)), expected, duration) + + def test_short_clip_still_gets_min_frames(self): + """5 秒的片子按每 5 秒一帧只有 1 帧,判不出分镜,必须兜到 MIN_FRAMES。""" + self.assertEqual(len(plan_frame_times(SECONDS_PER_FRAME)), MIN_FRAMES) + + def test_times_are_inside_the_clip_and_ordered(self): + times = plan_frame_times(22.2) + self.assertEqual(times, sorted(times)) + self.assertGreaterEqual(times[0], 0) + self.assertLess(times[-1], 22.2) + + def test_samples_midpoints_not_edges(self): + """首帧常是黑场、尾帧常是片尾卡片,取每段中点避开。""" + self.assertGreater(plan_frame_times(60)[0], 0) + + +class UploadGuardTests(SimpleTestCase): + def _upload(self, name: str, size: int = 1024): + return SimpleUploadedFile(name, b"x" * size, content_type="video/mp4") + + def test_rejects_non_video_suffix(self): + with self.assertRaises(VideoDigestError) as ctx: + frames_from_upload(self._upload("script.docx")) + self.assertIn("mp4", str(ctx.exception)) + + def test_rejects_oversized_file_before_touching_ffmpeg(self): + big = self._upload("big.mp4", size=8) + big.size = MAX_UPLOAD_BYTES + 1 + with self.assertRaises(VideoDigestError) as ctx: + frames_from_upload(big) + self.assertIn("MB", str(ctx.exception)) + + def test_rejects_unreadable_file(self): + with self.assertRaises(VideoDigestError): + frames_from_upload(self._upload("broken.mp4")) + + def test_rejects_clip_longer_than_cap(self): + """超长片抽样密度不够,拆出来是错的,宁可让用户先剪。""" + with self.assertRaises(VideoDigestError) as ctx: + _digest_with_duration(_synth_clip(seconds=1), MAX_DURATION_SECONDS + 1) + self.assertIn("分钟", str(ctx.exception)) + + def test_accepts_clip_within_cap_and_returns_frames(self): + frames, duration = frames_from_upload(_synth_clip(seconds=6)) + self.assertGreaterEqual(len(frames), MIN_FRAMES) + self.assertTrue(all(f.jpeg.startswith(b"\xff\xd8") for f in frames)) + self.assertAlmostEqual(duration, 6, delta=1) + + +class MessageBuildTests(SimpleTestCase): + frames = [VideoFrame(at_seconds=2, jpeg=b"\xff\xd8fake"), VideoFrame(at_seconds=7, jpeg=b"\xff\xd8fake2")] + + def test_system_prompt_is_the_digest_skill(self): + messages = build_digest_messages(self.frames, 10) + self.assertEqual(messages[0]["role"], "system") + self.assertIn("七要素", messages[0]["content"]) + + def test_every_frame_is_inlined_with_its_timestamp(self): + content = build_digest_messages(self.frames, 10)[1]["content"] + images = [c for c in content if c["type"] == "image_url"] + self.assertEqual(len(images), 2) + for image in images: + self.assertTrue(image["image_url"]["url"].startswith("data:image/jpeg;base64,")) + stamps = [c["text"] for c in content if c["type"] == "text"] + self.assertIn("[第 2 秒]", stamps) + self.assertIn("[第 7 秒]", stamps) + + def test_product_hint_reaches_the_model(self): + """带上商品,模型才会在存疑里提示「这条结构换到你的商品要改哪镜」。""" + content = build_digest_messages(self.frames, 10, product_hint="蓝牙耳机 · 数码3C")[1]["content"] + self.assertIn("蓝牙耳机 · 数码3C", content[0]["text"]) + + def test_skill_loads_from_disk(self): + self.assertIn("画面七要素", load_digest_skill()) + + +class DigestValidationTests(SimpleTestCase): + def test_rejects_empty_or_apologetic_output(self): + for bad in ["", " ", "抱歉,我无法处理这个请求。"]: + with self.assertRaises(ValueError): + validate_digest_text(bad) + + def test_rejects_prose_without_shot_blocks(self): + with self.assertRaises(ValueError): + validate_digest_text("这是一条很好的带货视频,节奏明快,画面精美。" * 5) + + def test_accepts_well_formed_digest(self): + good = "【整体结构】\n形式:口播\n" + "【第 1 镜】0-4 秒 · 钩子\n主体:一位女生\n" * 3 + self.assertEqual(validate_digest_text(f" {good} "), good.strip()) + + +# --------------------------------------------------------------------------- # +# helpers +# --------------------------------------------------------------------------- # +def _synth_clip(seconds: int) -> SimpleUploadedFile: + """用 ffmpeg 造一段纯色小视频当夹具,不依赖外部素材。""" + done = subprocess.run( + ["ffmpeg", "-v", "error", "-f", "lavfi", "-i", f"color=c=orange:s=320x180:d={seconds}", + "-c:v", "libx264", "-pix_fmt", "yuv420p", "-f", "mp4", "-movflags", "frag_keyframe+empty_moov", "-"], + capture_output=True, check=True, + ) + return SimpleUploadedFile("clip.mp4", done.stdout, content_type="video/mp4") + + +def _digest_with_duration(clip, duration: float): + """把探到的时长顶成 duration,验时长闸门,不必真造一条 3 分钟的片。""" + from unittest.mock import patch + + with patch("apps.ai.video_digest.probe_duration", return_value=duration): + return frames_from_upload(clip) diff --git a/core/backend/apps/ai/video_digest.py b/core/backend/apps/ai/video_digest.py new file mode 100644 index 0000000..bd6c8a9 --- /dev/null +++ b/core/backend/apps/ai/video_digest.py @@ -0,0 +1,295 @@ +"""上传视频提炼 —— 参考视频 → 可人工逐镜编辑的中文分镜稿。 + +链路:ffmpeg 抽帧(均匀采样) → 帧内联进多模态 messages → 走现有文本模型路由 → 纯文本分镜稿。 + +两个「本来以为要新建、其实已经有」的前提: + 1. **读图能力**:默认脚本模型(YunQi gemini-3.1-pro)本身是多模态的,OpenAI 兼容的 + ``content: [{type:"text"},{type:"image_url"}]`` 直接透传即可,不需要新 provider、新模型、新 key。 + 2. **ffmpeg**:第 5 阶段导出早就依赖它,已装进后端镜像(见 Dockerfile)。 + +**没有音轨**:帧里读不到口播,只能读画面上的字幕。这是刻意取舍——接语音转写要另开火山 ASR 服务、 +另加一套凭证与计价,而带货参考片绝大多数带硬字幕,且口播词下游本来就要按用户自己的商品重写。 +skill 里已要求模型「无字幕就如实写缺失,不许编口播词」。 +""" + +from __future__ import annotations + +import base64 +import json +import math +import shutil +import subprocess +import tempfile +from dataclasses import dataclass +from functools import lru_cache +from pathlib import Path + +from django.conf import settings + +# 上传限制:超了直接 400,不进 ffmpeg,也不花模型钱 +ALLOWED_SUFFIXES = (".mp4", ".mov", ".m4v", ".webm") +MAX_UPLOAD_BYTES = 200 * 1024 * 1024 # 200 MB +MAX_DURATION_SECONDS = 180 # 3 分钟。带货参考片远短于此;更长的帧采样密度不够,拆出来也是错的 + +# 抽帧:每 5 秒一帧,夹在 4~12 帧之间。12 帧 × 512px JPEG ≈ 0.5 MB base64,单次请求扛得住 +SECONDS_PER_FRAME = 5 +MIN_FRAMES = 4 +MAX_FRAMES = 12 +FRAME_WIDTH = 512 # 帧宽上限;分镜拆解看的是构图与景别,不需要原分辨率 +FRAME_QUALITY = 5 # ffmpeg -q:v,2(最好)~31(最差) + +_FFMPEG_TIMEOUT = 60 + + +class VideoDigestError(ValueError): + """用户可见的失败(文件不合格 / ffmpeg 读不动),一律 400。""" + + +@dataclass(frozen=True) +class VideoFrame: + at_seconds: int + jpeg: bytes + + def as_data_url(self) -> str: + return "data:image/jpeg;base64," + base64.b64encode(self.jpeg).decode("ascii") + + +# --------------------------------------------------------------------------- # +# skill 加载 +# --------------------------------------------------------------------------- # +def _skill_dir() -> Path: + """与 script_agent._skill_dir 同源:优先 BASE_DIR/skills(镜像内),回落仓库根(本地旧布局)。""" + base = Path(settings.BASE_DIR) + for cand in (base / "skills", base.parent.parent / "skills"): + if (cand / "video-shot-digest").is_dir(): + return cand / "video-shot-digest" + return base / "skills" / "video-shot-digest" + + +@lru_cache(maxsize=1) +def load_digest_skill() -> str: + main = _skill_dir() / "SKILL.md" + if main.exists(): + return main.read_text(encoding="utf-8") + # 兜底:skill 丢了也别整条链路挂掉,退化成一句话提示词(产出会明显变差,交接文档已注明须带 skills 目录) + return ( + "你是分镜拆解 agent。输入是一条电商短视频按时间均匀抽出的截帧。" + "逐镜还原分镜,每镜写清主体/动作/场景/景别/运镜/光线氛围/商品露出七要素," + "台词只抄画面上的字幕,看不见的不要编。输出中文纯文本。" + ) + + +# --------------------------------------------------------------------------- # +# ffmpeg:探时长 + 抽帧 +# --------------------------------------------------------------------------- # +def _binary(name: str) -> str: + found = shutil.which(name) + if not found: + raise VideoDigestError("服务器暂时无法解析视频,请稍后再试") + return found + + +def probe_duration(path: str | Path) -> float: + """ffprobe 读时长(秒)。读不到 = 不是能解的视频。""" + try: + out = subprocess.run( + [ + _binary("ffprobe"), "-v", "error", + "-print_format", "json", "-show_format", + str(path), + ], + capture_output=True, timeout=_FFMPEG_TIMEOUT, check=True, + ).stdout + duration = float(json.loads(out)["format"]["duration"]) + except VideoDigestError: + raise + except Exception as exc: # noqa: BLE001 — ffprobe 各种失败对用户是同一件事 + raise VideoDigestError("这个视频读不出来,请换一个 mp4 / mov 文件") from exc + if duration <= 0: + raise VideoDigestError("这个视频读不出来,请换一个 mp4 / mov 文件") + return duration + + +def plan_frame_times(duration: float) -> list[int]: + """均匀采样时间点。取每段的**中点**,避开首尾黑场与片尾卡片。""" + count = max(MIN_FRAMES, min(MAX_FRAMES, math.ceil(duration / SECONDS_PER_FRAME))) + step = duration / count + return [int(step * (i + 0.5)) for i in range(count)] + + +def extract_frames(path: str | Path, times: list[int]) -> list[VideoFrame]: + """逐时间点抽一帧。``-ss`` 放在 ``-i`` 前走关键帧快速定位,每帧约几十毫秒。""" + ffmpeg = _binary("ffmpeg") + frames: list[VideoFrame] = [] + for at in times: + try: + done = subprocess.run( + [ + ffmpeg, "-v", "error", "-ss", str(at), "-i", str(path), + "-frames:v", "1", "-vf", f"scale={FRAME_WIDTH}:-2", + "-q:v", str(FRAME_QUALITY), "-f", "image2", "-", + ], + capture_output=True, timeout=_FFMPEG_TIMEOUT, check=True, + ) + except Exception: # noqa: BLE001 — 单帧抽失败(定位越界等)跳过,别拖垮整次提炼 + continue + if done.stdout: + frames.append(VideoFrame(at_seconds=at, jpeg=done.stdout)) + if not frames: + raise VideoDigestError("没能从这个视频里取到画面,请换一个文件") + return frames + + +def frames_from_upload(upload) -> tuple[list[VideoFrame], float]: + """校验上传文件 → 落临时盘 → 探时长 → 抽帧。临时文件退出即删。""" + name = (getattr(upload, "name", "") or "").lower() + if not name.endswith(ALLOWED_SUFFIXES): + raise VideoDigestError("只支持 mp4 / mov / m4v / webm 四种视频格式") + size = getattr(upload, "size", 0) or 0 + if size > MAX_UPLOAD_BYTES: + raise VideoDigestError(f"视频不能超过 {MAX_UPLOAD_BYTES // 1024 // 1024} MB,请压缩后再传") + + suffix = Path(name).suffix or ".mp4" + with tempfile.NamedTemporaryFile(suffix=suffix) as tmp: + for chunk in upload.chunks(): + tmp.write(chunk) + tmp.flush() + duration = probe_duration(tmp.name) + if duration > MAX_DURATION_SECONDS: + raise VideoDigestError( + f"视频不能超过 {MAX_DURATION_SECONDS // 60} 分钟,请剪出要参考的那一段再传" + ) + return extract_frames(tmp.name, plan_frame_times(duration)), duration + + +# --------------------------------------------------------------------------- # +# 组装多模态消息 +# --------------------------------------------------------------------------- # +def build_digest_messages( + frames: list[VideoFrame], + duration: float, + *, + product_hint: str = "", +) -> list[dict]: + """system = 拆解 skill;user = 时间戳 + 帧图交替,让模型知道每张图在原片的第几秒。""" + head = [ + f"这是一条时长约 {round(duration)} 秒的电商带货短视频,", + f"按时间顺序均匀抽了 {len(frames)} 帧。每帧图前面标了它在原片中的时间点。", + ] + if product_hint: + head.append(f"用户接下来想用这条片子的结构去拍自己的商品:{product_hint}。") + head.append("请按技能里的输出格式还原它的分镜。") + + content: list[dict] = [{"type": "text", "text": "".join(head)}] + for frame in frames: + content.append({"type": "text", "text": f"[第 {frame.at_seconds} 秒]"}) + content.append({"type": "image_url", "image_url": {"url": frame.as_data_url()}}) + return [ + {"role": "system", "content": load_digest_skill()}, + {"role": "user", "content": content}, + ] + + +def validate_digest_text(text: str) -> str: + """模型偶尔吐空 / 吐一句道歉。判空后交给路由层重试或切模型,别把废稿塞给用户。""" + cleaned = (text or "").strip() + if len(cleaned) < 80 or "【" not in cleaned: + raise ValueError("视频拆解结果不完整") + return cleaned + + +# --------------------------------------------------------------------------- # +# 入口:一次真实的计费调用 +# --------------------------------------------------------------------------- # +def digest_project_video(*, project, user, upload) -> dict: + """上传视频 → 分镜稿。抽帧在建任务之前做,文件不合格不占积分。""" + from django.db import transaction + from django.utils import timezone + + from apps.ai.models import AITask, ModelConfig + from apps.ai.services import create_ai_task, execute_routed_text_request, get_default_model + from apps.billing.services.ledger import charge_reserved_credit + + frames, duration = frames_from_upload(upload) + + model_config = get_default_model(ModelConfig.Capability.TEXT) + if model_config is None: + raise VideoDigestError("暂时没有可用的模型,请联系管理员") + + product = getattr(project, "product", None) + messages = build_digest_messages( + frames, + duration, + product_hint=" · ".join( + filter(None, [getattr(product, "title", ""), getattr(product, "category", "")]) + ), + ) + + task = create_ai_task( + project=project, + user=user, + task_type=AITask.Type.VIDEO_DIGEST, + model_config=model_config, + # 帧是几百 KB base64,绝不进 request_payload(会把 AITask 表撑爆),只记形状 + request_payload={ + "model": model_config.name, + "endpoint": model_config.endpoint, + "duration_seconds": round(duration, 2), + "frame_count": len(frames), + "frame_times": [f.at_seconds for f in frames], + }, + ) + reservation = task.credit_reservation + try: + task.status = AITask.Status.SUBMITTED + task.submitted_at = timezone.now() + task.save(update_fields=["status", "submitted_at", "updated_at"]) + routed = execute_routed_text_request( + task=task, + primary_model=model_config, + messages=messages, + streaming=False, + structured_output=False, + business_operation="video_digest", + temperature=0.4, + validate_text=validate_digest_text, + request_summary={"duration_seconds": round(duration, 2), "frame_count": len(frames)}, + ) + _text, _response, digest = routed.value + except Exception as exc: # noqa: BLE001 + _fail_digest_task(task, reservation, str(exc)) + raise + + with transaction.atomic(): + task.status = AITask.Status.SUCCEEDED + task.response_payload = {"digest": digest[:8000]} + task.actual_cost = task.estimated_cost + task.completed_at = timezone.now() + task.save(update_fields=["status", "response_payload", "actual_cost", "completed_at", "updated_at"]) + charge_reserved_credit(reservation=reservation, actual_amount=task.actual_cost) + + return { + "text": digest, + "chars": len(digest), + "frames": len(frames), + "duration": round(duration, 1), + "task_id": str(task.id), + } + + +def _fail_digest_task(task, reservation, message: str) -> None: + from django.utils import timezone + + from apps.ai.models import AITask + from apps.billing.services.ledger import release_credit + + try: + task.status = AITask.Status.FAILED + task.error_message = message[:2000] + task.completed_at = timezone.now() + task.save(update_fields=["status", "error_message", "completed_at", "updated_at"]) + finally: + try: + release_credit(reservation=reservation, reason=message[:200]) + except Exception: # noqa: BLE001 + pass diff --git a/core/backend/apps/assets/review.py b/core/backend/apps/assets/review.py index 96cc746..b6b8640 100644 --- a/core/backend/apps/assets/review.py +++ b/core/backend/apps/assets/review.py @@ -59,11 +59,16 @@ def get_or_create_team_group(team) -> AssetReviewGroup: return grp -def submit_asset_for_review(asset: Asset) -> bool: +def submit_asset_for_review(asset: Asset, *, force: bool = False) -> bool: """真人资产送审:建组(若无)→ 传素材 → 标 processing。出错只记日志,不抛。 返回是否真正进入审核(True=已标 processing;False=未送审/未配置/失败), - 供手动兜底端点据此如实回报,避免前端把「没送出去」误显示成「审核中」。""" - if not assets_client.is_enabled() or asset.category not in Asset.REVIEW_CATEGORIES: + 供手动兜底端点据此如实回报,避免前端把「没送出去」误显示成「审核中」。 + + force=True 跳过 REVIEW_CATEGORIES 白名单:用户上传的资产被拿去当生成参考时, + 我们无从判断里面有没有真人脸,一律登记一次(与人物素材库上传同策略)。""" + if not assets_client.is_enabled(): + return False + if not force and asset.category not in Asset.REVIEW_CATEGORIES: return False url = _asset_url(asset) if not url: @@ -85,6 +90,32 @@ def submit_asset_for_review(asset: Asset) -> bool: return False +# 平台自己生成的资产,提示词与生成链路都在我们手里,视为免审;用户上传的必须真过一遍审核。 +# 判据是 Asset.source 而不是「在不在某个库里」—— 按库免审等于把审核架空: +# 用户上传一张图进资产库,再从自由创作引用出去,就绕过了整套人像审核。 +SELF_TRUSTED_SOURCES = (Asset.Source.AI_GENERATED, Asset.Source.SYSTEM) + + +def reference_review_state(asset: Asset) -> str: + """引用一个平台资产(自由创作 @引用三库)前的审核判定。 + + 返回 allowed / processing / failed / unsubmitted 四态之一,由调用方决定放行还是给提示。 + 未送审(unsubmitted)不代表拒绝到底 —— 调用方应顺手送一次审,让用户等一会儿再来。 + """ + if not assets_client.is_enabled(): + # 审核整套机制没配置时不能拿它拦人,否则一关审核全平台引用都用不了 + return "allowed" + if asset.source in SELF_TRUSTED_SOURCES: + return "allowed" + if asset.review_status == "active": + return "allowed" + if asset.review_status == "processing": + return "processing" + if asset.review_status == "failed": + return "failed" + return "unsubmitted" + + def poll_asset_review(asset: Asset) -> str: """查单个真人资产审核状态并更新 review_status。返回最新状态。 只在状态变化时落库(保留 updated_at 作为「进入 processing 的时刻」);processing 超时兜底为 failed。""" diff --git a/core/backend/apps/assets/tests.py b/core/backend/apps/assets/tests.py index 9fb0ee4..60a85b5 100644 --- a/core/backend/apps/assets/tests.py +++ b/core/backend/apps/assets/tests.py @@ -853,7 +853,7 @@ class SubmitReviewTests(TestCase): def test_submit_review_200_when_started(self): """真正进入审核(processing)→ 200 + 真态,供前端落 processing。""" - def _mark_processing(asset): + def _mark_processing(asset, *, force=False): asset.review_status = "processing" asset.save(update_fields=["review_status"]) return True diff --git a/core/backend/apps/assets/views.py b/core/backend/apps/assets/views.py index 4a582fb..e2966f4 100644 --- a/core/backend/apps/assets/views.py +++ b/core/backend/apps/assets/views.py @@ -265,13 +265,18 @@ class AssetViewSet(TeamScopedViewSetMixin, ModelViewSet): @action(detail=True, methods=["post"], url_path="submit-review") def submit_review(self, request, pk=None): """手动兜底:把单个送审类素材(角色/三视图/分镜)送火山审核 —— 用户点灰盾「待审核」时触发。 - 正常走 on_commit 自动送审;此口给万一漏提交时补提交。非送审类拒绝。""" + 正常走 on_commit 自动送审;此口给万一漏提交时补提交。非送审类拒绝。 + + for_reference=true:素材要被拿去当生成参考(自由创作 @引用三库)。此时不再看类目白名单—— + 白名单只是「别为无关素材白跑一趟审核」的优化,而参考素材里有没有真人脸我们无从判断, + 一律登记一次。放宽的是送审范围,不是放行范围,不会削弱审核。""" from apps.assets.review import submit_asset_for_review asset = self.get_object() # team-scoped + is_deleted 过滤 - if asset.category not in Asset.REVIEW_CATEGORIES: + for_reference = bool(request.data.get("for_reference")) + if not for_reference and asset.category not in Asset.REVIEW_CATEGORIES: return Response({"detail": "该素材无需审核"}, status=status.HTTP_400_BAD_REQUEST) - started = submit_asset_for_review(asset) + started = submit_asset_for_review(asset, force=for_reference) asset.refresh_from_db() # 没真正送出去(审核服务未启用/未配置 SDK/火山未回 Id/调用异常),且素材也不是已终态: # 如实回报 503,别返回空 review_status —— 否则前端会把「没送出去」误显示成「审核中」,刷新就打回原形。 diff --git a/core/backend/apps/projects/services/script_import.py b/core/backend/apps/projects/services/script_import.py new file mode 100644 index 0000000..6e4a2f9 --- /dev/null +++ b/core/backend/apps/projects/services/script_import.py @@ -0,0 +1,77 @@ +"""上传脚本取正文 —— 只认 docx / txt。 + +docx 用标准库拆(zip + xml),不引 python-docx:镜像少一个依赖,也避免它对损坏文件抛一堆内部异常。 +段落 = ,文字 = 其中所有 拼接;/ 补空白,否则整段会粘成一坨。 +""" + +import re +import zipfile +from io import BytesIO +from xml.etree import ElementTree + +W_NS = "http://schemas.openxmlformats.org/wordprocessingml/2006/main" +MAX_UPLOAD_BYTES = 5 * 1024 * 1024 # 脚本是纯文字,5MB 足够;更大的基本是误传 + + +class ScriptFileError(ValueError): + """取正文失败,message 直接给用户看(中文)。""" + + +def _docx_text(raw: bytes) -> str: + try: + with zipfile.ZipFile(BytesIO(raw)) as bundle: + document = bundle.read("word/document.xml") + except KeyError: + raise ScriptFileError("这个 docx 里没有正文,请确认文件没有损坏") + except zipfile.BadZipFile: + raise ScriptFileError("这个文件不是有效的 docx,请另存为 .docx 后重试") + + try: + root = ElementTree.fromstring(document) + except ElementTree.ParseError: + raise ScriptFileError("docx 正文解析失败,请另存一份后重试") + + paragraphs = [] + for node in root.iter(f"{{{W_NS}}}p"): + pieces = [] + for child in node.iter(): + tag = child.tag + if tag == f"{{{W_NS}}}t": + pieces.append(child.text or "") + elif tag in (f"{{{W_NS}}}br", f"{{{W_NS}}}cr"): + pieces.append("\n") + elif tag == f"{{{W_NS}}}tab": + pieces.append("\t") + line = "".join(pieces).strip() + if line: + paragraphs.append(line) + return "\n".join(paragraphs) + + +def _txt_text(raw: bytes) -> str: + # 中文 txt 常见三种落盘编码;utf-8 失败再退 GBK 家族,最后一步忽略坏字节保底出文本。 + for encoding in ("utf-8-sig", "utf-8", "gb18030"): + try: + return raw.decode(encoding) + except UnicodeDecodeError: + continue + return raw.decode("utf-8", errors="ignore") + + +def extract_script_text(upload) -> tuple[str, str]: + """(文件名, 正文)。只收 docx / txt,其余一律拒绝。""" + name = getattr(upload, "name", "") or "script" + lowered = name.lower() + if not lowered.endswith((".docx", ".txt")): + raise ScriptFileError("只支持 docx 和 txt 两种脚本文件") + + raw = upload.read() + if len(raw) > MAX_UPLOAD_BYTES: + raise ScriptFileError("脚本文件不能超过 5MB") + + text = _docx_text(raw) if lowered.endswith(".docx") else _txt_text(raw) + # 统一换行 + 压掉连续空行,避免原稿的排版空白撑爆后面的提示词 + text = re.sub(r"\n{3,}", "\n\n", text.replace("\r\n", "\n").replace("\r", "\n")).strip() + if not text: + raise ScriptFileError("没能从这个文件里读到文字,请换一份") + return name, text diff --git a/core/backend/apps/projects/views.py b/core/backend/apps/projects/views.py index 4a91b8d..282f51b 100644 --- a/core/backend/apps/projects/views.py +++ b/core/backend/apps/projects/views.py @@ -15,7 +15,13 @@ from rest_framework.viewsets import ModelViewSet from apps.ai.models import AITask, ModelConfig from apps.ai.providers import TtsNotConfigured -from apps.ai.script_agent import stream_script_agent +from apps.ai.script_agent import ( + SEGMENT_DURATION_MAX, + SEGMENT_DURATION_MIN, + coerce_combo, + coerce_total_duration, + stream_script_agent, +) from apps.ai.services import ( DEFAULT_VOICEOVER_VOICE, VOICEOVER_VOICES, @@ -39,6 +45,7 @@ from apps.assets.storage import TosStorage from apps.common.api import TeamScopedViewSetMixin from apps.common.celery_health import require_worker from apps.ai.generation_errors import classify_generation_error, public_error_for_task +from apps.ai.video_digest import VideoDigestError, digest_project_video from .models import ( BaseAssetGroup, @@ -69,6 +76,7 @@ from .serializers import ( ) from .services.export import run_export_job_in_thread from .services.pipeline import STAGE_ORDER +from .services.script_import import ScriptFileError, extract_script_text from .tasks import poll_video_segment_task logger = logging.getLogger(__name__) @@ -417,14 +425,18 @@ class ProjectViewSet(TeamScopedViewSetMixin, ModelViewSet): project = serializer.save(team=self.get_team(), created_by=self.request.user) for stage in STAGE_ORDER: ProjectStage.objects.create(project=project, stage=stage) + # 先铺 4 段占位;真实段数与每段时长在采用脚本时由 _sync_video_segments_to_script 收口 for index in range(4): - VideoSegment.objects.create(project=project, sort_order=index, target_duration_seconds=15) + VideoSegment.objects.create( + project=project, sort_order=index, target_duration_seconds=SEGMENT_DURATION_MAX + ) @action(detail=True, methods=["post"], url_path="script-agent-stream", renderer_classes=[ServerSentEventRenderer]) def script_agent_stream(self, request, pk=None): """对话式脚本 agent · 流式(SSE)。出稿 + 改稿一体,多模型可选。 请求体:mode(auto|theme|revise)、prompt、model_config_id、selling_point_ids、 - base_version_id(改稿)、aspect_ratio、total_duration。 + base_version_id(改稿)、aspect_ratio、total_duration(5-60,5 秒步进)、 + presentation_format(oral|drama|vlog)、video_structure(pain|contrast|review|scene)。 响应:text/event-stream,逐帧吐 tool/delta/draft/saved/done/error。""" project = self.get_object() mode = str(request.data.get("mode") or "auto") @@ -432,10 +444,12 @@ class ProjectViewSet(TeamScopedViewSetMixin, ModelViewSet): selling_point_ids = request.data.get("selling_point_ids") or [] base_version_id = request.data.get("base_version_id") or None aspect_ratio = str(request.data.get("aspect_ratio") or "9:16") - try: - total_duration = int(request.data.get("total_duration") or 60) - except (TypeError, ValueError): - total_duration = 60 + # 非法值一律由 agent 侧 coerce 兜底(夹区间/回落默认),这里不做 400,避免生成被参数噪声打断 + total_duration = coerce_total_duration(request.data.get("total_duration")) + presentation_format, video_structure = coerce_combo( + request.data.get("presentation_format"), + request.data.get("video_structure"), + ) target_index = request.data.get("target_index") try: target_index = int(target_index) if target_index is not None else None @@ -466,7 +480,10 @@ class ProjectViewSet(TeamScopedViewSetMixin, ModelViewSet): base_version_id=base_version_id, aspect_ratio=aspect_ratio, total_duration=total_duration, + presentation_format=presentation_format, + video_structure=video_structure, target_index=target_index, + entry_source=str(request.data.get("source") or ""), ) response = StreamingHttpResponse(stream, content_type="text/event-stream") response["Cache-Control"] = "no-cache" @@ -790,11 +807,16 @@ class ProjectViewSet(TeamScopedViewSetMixin, ModelViewSet): # ── Stage 1 · 镜头脚本逐字段编辑 / 增删分镜 ── def _sync_video_segments_to_script(self, project: Project, script: ScriptVersion) -> None: - """采用版分镜数变化时,同步 VideoSegment 数量:不足则尾部补 NOT_STARTED, - 多出且尾部是「从未生成过」的段则裁掉(已生成的段绝不动)。""" + """采用版分镜数变化时,同步 VideoSegment 的**数量和时长**:不足则尾部补 NOT_STARTED, + 多出且尾部是「从未生成过」的段则裁掉(已生成的段绝不动)。 + + 时长同步是二期补的:脚本镜可以不等长了,而出片、计价、时间线读的都是 + VideoSegment.target_duration_seconds。不同步的话脚本写了 8 秒、出片仍按 15 秒跑。 + 已出片的段不改时长——改了会跟已渲染的成片对不上。""" if not script.is_adopted: return - target = script.segments.count() + script_segments = list(script.segments.order_by("sort_order")) + target = len(script_segments) segments = list(project.video_segments.order_by("sort_order")) while len(segments) > target: tail = segments[-1] @@ -805,9 +827,34 @@ class ProjectViewSet(TeamScopedViewSetMixin, ModelViewSet): break next_order = (segments[-1].sort_order + 1) if segments else 0 for _ in range(target - len(segments)): - segments.append(VideoSegment.objects.create(project=project, sort_order=next_order, target_duration_seconds=15)) + index = len(segments) + seconds = ( + script_segments[index].duration_seconds + if index < target + else SEGMENT_DURATION_MAX + ) + segments.append( + VideoSegment.objects.create( + project=project, sort_order=next_order, target_duration_seconds=seconds + ) + ) next_order += 1 + # 已存在的段:只对「还没出过片」的回填脚本时长,已渲染的保持原样 + stale: list[VideoSegment] = [] + for index, video_segment in enumerate(segments): + if index >= target: + break + seconds = script_segments[index].duration_seconds + if not seconds or video_segment.target_duration_seconds == seconds: + continue + if video_segment.status == VideoSegment.Status.SUCCEEDED or video_segment.versions.exists(): + continue + video_segment.target_duration_seconds = seconds + stale.append(video_segment) + if stale: + VideoSegment.objects.bulk_update(stale, ["target_duration_seconds"]) + def _sync_storyboard_shots_to_script(self, project: Project, script: ScriptVersion) -> None: """采用版分镜数变化时,同步 StoryboardShot 数量(与视频段同策略,按位置对齐): 多出且尾部「从未出过图」的 shot 裁掉(已出图的不动);不主动建——出图时 ensure_storyboard_shots 按需补。""" @@ -834,7 +881,10 @@ class ProjectViewSet(TeamScopedViewSetMixin, ModelViewSet): changed.append(field) if "duration_seconds" in request.data: try: - segment.duration_seconds = max(1, min(60, int(request.data["duration_seconds"]))) + # 单镜必须落在 4–15 秒:出片模型的硬上限,越界下游直接拒片 + segment.duration_seconds = max( + SEGMENT_DURATION_MIN, min(SEGMENT_DURATION_MAX, int(request.data["duration_seconds"])) + ) changed.append("duration_seconds") except (TypeError, ValueError): pass @@ -877,7 +927,10 @@ class ProjectViewSet(TeamScopedViewSetMixin, ModelViewSet): created = ScriptSegment.objects.create( script_version=script, sort_order=insert_at, - duration_seconds=int(request.data.get("duration_seconds") or 15), + duration_seconds=max( + SEGMENT_DURATION_MIN, + min(SEGMENT_DURATION_MAX, int(request.data.get("duration_seconds") or SEGMENT_DURATION_MAX)), + ), narration=str(request.data.get("narration") or "").strip(), visual_prompt=str(request.data.get("visual_prompt") or "").strip(), ) @@ -1187,6 +1240,39 @@ class ProjectViewSet(TeamScopedViewSetMixin, ModelViewSet): segment.save(update_fields=["adopted_version", "status", "error_message", "updated_at"]) return Response(ProjectSerializer(project).data, status=status.HTTP_201_CREATED) + @action(detail=True, methods=["post"], url_path="extract-script-file", parser_classes=[MultiPartParser, FormParser]) + def extract_script_file_action(self, request, pk=None): + """上传脚本取正文:docx / txt → 纯文本。只读不落库,交前端填进对话框再走脚本 agent。""" + self.get_object() # 走一遍团队权限校验 + upload = request.data.get("file") + if upload is None: + return Response({"detail": "no file uploaded"}, status=status.HTTP_400_BAD_REQUEST) + try: + name, text = extract_script_text(upload) + except ScriptFileError as exc: + return Response({"detail": str(exc)}, status=status.HTTP_400_BAD_REQUEST) + return Response({"name": name, "chars": len(text), "text": text}) + + @action(detail=True, methods=["post"], url_path="extract-script-video", parser_classes=[MultiPartParser, FormParser]) + def extract_script_video_action(self, request, pk=None): + """上传视频提炼:参考视频 → 中文分镜稿。 + + 与 extract-script-file 同形状(只读不落脚本),交前端填进对话框让用户逐镜改, + 改完再走脚本 agent 出结构化稿。差别是这条要真调模型,故按一次文本任务计费。 + """ + project = self.get_object() + upload = request.data.get("file") + if upload is None: + return Response({"detail": "no file uploaded"}, status=status.HTTP_400_BAD_REQUEST) + try: + result = digest_project_video(project=project, user=request.user, upload=upload) + except VideoDigestError as exc: + return Response({"detail": str(exc)}, status=status.HTTP_400_BAD_REQUEST) + except Exception as exc: # noqa: BLE001 — 模型/网络失败:走统一安全文案,不回传原始异常 + public_error = classify_generation_error(exc, operation="video_digest") + return Response(public_error, status=status.HTTP_502_BAD_GATEWAY) + return Response({"name": getattr(upload, "name", "") or "参考视频", **result}) + @action(detail=True, methods=["post"], url_path="upload-bgm", parser_classes=[MultiPartParser, FormParser]) @transaction.atomic def upload_bgm_action(self, request, pk=None): diff --git a/core/backend/skills/ecommerce-video-script/SKILL.md b/core/backend/skills/ecommerce-video-script/SKILL.md index c099927..12fdb27 100644 --- a/core/backend/skills/ecommerce-video-script/SKILL.md +++ b/core/backend/skills/ecommerce-video-script/SKILL.md @@ -4,7 +4,9 @@ description: > 电商带货短视频·脚本生成领域技能(模型无关)。 服务对象不是人类编剧,而是 AirShelf 产品后端的「脚本生成 agent」——在运行时按需加载本技能作为领域知识。 能力:把【商品信息 + 前置条件】或【一句话主题】或【用户已有脚本】, - 自动收敛成一份结构化的带货短视频脚本 JSON(默认 9:16 竖屏可改 / 时长 15·30·60·90 秒四档 / 每 15 秒一镜)。 + 自动收敛成一份结构化的带货短视频脚本 JSON + (默认 9:16 竖屏可改 / 总时长 5–60 秒按 5 秒步进 / 单镜 4–15 秒可不等长 / + 按「表现形式 × 视频结构」套路生成)。 当任务为「生成带货脚本 / 扩写主题 / 优化已有脚本 / 商品转视频脚本」时使用本技能。 核心目标是「电商小白点一下按钮就出能吸睛、能转化的脚本」,不是影视级艺术性。 --- @@ -29,9 +31,11 @@ description: > { "hook": "前3秒主打钩子(一句话)", "tone": "种草|测评|剧情|痛点", + "presentation_format": "口播|短剧|Vlog", + "video_structure": "痛点解决|前后对比|测评验证|场景种草", "aspect_ratio": "9:16", - "total_duration": 60, - "segment_count": 4, + "total_duration": 30, + "segment_count": 3, "entities": [ { "id": "c1", @@ -53,11 +57,11 @@ description: > "segments": [ { "index": 0, - "duration": 15, + "duration": 12, "role": "钩子|痛点|卖点|CTA", - "narration": "这一镜被说出来的台词/旁白,≤55字", + "narration": "这一镜被说出来的台词/旁白,字数上限=duration×3.5", "speaker": "可选,指向某 entity 的 id;画外旁白时为 null", - "visual": "这一镜的画面:主体+动作+景别/运镜(特写/全景/手持跟拍/推拉摇)+一个画面或情绪的变化,够导演撑满15秒,约40-70字,别只写一句静态动作", + "visual": "这一镜的画面:主体+动作+景别/运镜(特写/全景/手持跟拍/推拉摇)+一个画面或情绪的变化,够导演撑满这一镜的秒数,约40-70字,别只写一句静态动作", "product_exposure": "商品露出方式(手持/特写/使用中)", "entity_refs": ["c1", "s1"], "dialogue": [] @@ -68,20 +72,29 @@ description: > 字段纪律: - `tone` 必须是四选一枚举;`role` 必须是四选一枚举。 +- **表现形式与视频结构由输入给定**:`presentation_format`(`口播|短剧|Vlog`)和 + `video_structure`(`痛点解决|前后对比|测评验证|场景种草`)**原样回填到输出里**,不要自己改。 + 输入没给时,按商品品类与人群自行选一组最合适的,并如实填进这两个字段。 - **画幅由输入给定**:`aspect_ratio` 默认 `"9:16"`(电商竖屏主场景),但**不写死**——输入指定了其他比例(如 `"16:9"`、`"1:1"`、`"4:5"`)就照用,原样透传给下游。画幅只影响 `visual` 的构图措辞,不改变结构与镜数。 -- **时长档位由输入给定**:`total_duration` 只能取 `15 | 30 | 60 | 90` 之一(输入未指定时默认 `60`)。**不要写死。** -- **每 15 秒切一镜**:`segment_count = total_duration / 15`,即 15→1 镜、30→2 镜、60→4 镜、90→6 镜;每镜 `duration=15`;`index` 从 0 连续递增(粗暴切,不做复杂时长算法)。 -- 各档位的 4 镜功能(role)如何分配/压缩/扩展,见 `references/methodology.md`「档位 × 黄金结构映射」。 +- **总时长由输入给定**:`total_duration` 取 **5–60 秒之间的 5 的倍数**(5/10/15/…/60),输入未指定时默认 `30`。**不要写死。** +- **单镜时长 4–15 秒,允许不等长**:每个 `segments[].duration` 必须是 **4 到 15 之间的整数** + (15 秒是下游出片模型的硬上限,越界下游直接拒绝出片)。 + 该长的镜给足、该短的镜压短,**不要机械均分**。 +- **镜时长必须精确加总**:`sum(segments[].duration) == total_duration`,一秒都不能差。 +- **`segment_count` = `segments` 的实际长度**,且 `index` 从 0 连续递增。 +- 镜数怎么定、role 怎么按镜数分配,见 `references/methodology.md`「时长 → 镜数 → 黄金结构映射」。 - `entities[].id` 全局唯一,`segments[].entity_refs` 与 `speaker` 只能引用已声明的 id。 - **发声方式每镜自己判断**(不强求统一,看这一镜的内容和场景): - **旁白/口播** → 填 `narration`,`dialogue` 留空 `[]`(大多数电商镜是这种); - **角色对话** → 填 `dialogue`:元素为 `{"speaker":"角色 entity 的 id,或 null=旁白","line":"台词"}`,每条 `line` ≤55 字;并把各 `line` 拼进 `narration` 兜底下游字幕/配音。剧情向、多角色互动、或一句自然的吐槽/接话更带感时都可以用,**不必非到「剧情」档**; - **纯画面展示** → `narration` 与 `dialogue` 都留空(没人说话,只有画面)。 - - **判断权交给你**:依据用户输入与这一镜的功能/场景决定。**不是每个 15 秒都得有对白,也不必死守旁白**;但**别无故给每镜都塞对白**(那样很假),自然才好。 + - **判断权交给你**:依据用户输入与这一镜的功能/场景决定。**不是每一镜都得有对白,也不必死守旁白**;但**别无故给每镜都塞对白**(那样很假),自然才好。 + - 发声方式还受**表现形式**约束:口播只用 `narration`、短剧必须用 `dialogue`、Vlog 以画外旁白为主。 + 以 `references/playbooks/format-*.md` 里那一份为准。 - **每个声明的 entity 至少被一个 segment 引用**(不留孤儿 entity)。 - **场景必抽,且每镜必绑一个场景**:每条脚本**至少声明 1 个 `type:"scene"` 实体**表示画面所在环境;**每个 segment 的 `entity_refs` 必须恰好引用一个 scene**。多镜在同一环境就**复用同一个 scene id**(绝不为同一环境写两份 visual_prompt,否则下游背景漂移);只有真正换了环境才新建另一个 scene。纯产品特写镜也要绑它所处环境的 scene(如「宿舍书桌」「厨房台面」),没有合适环境时复用主场景。 - `visual_prompt` 由你自动生成,小白无需打字。 -- **每镜 `visual` 要够厚撑满 15 秒**:一段话写清 ①主体+动作 ②景别/运镜(特写/全景/手持跟拍/推拉摇,至少给一个镜头语言)③一个画面或情绪的变化(从…到…)。约 **40–70 字**,**禁止只写一句静态动作**(如「女主举起商品展示」撑不住 15 秒,要补镜头与变化)。注意:这是给生图/视频导演的画面,**不占 narration 的 55 字额度**。 +- **每镜 `visual` 要够厚撑满这一镜的秒数**:一段话写清 ①主体+动作 ②景别/运镜(特写/全景/手持跟拍/推拉摇,至少给一个镜头语言)③一个画面或情绪的变化(从…到…)。约 **40–70 字**,**禁止只写一句静态动作**(如「女主举起商品展示」撑不住十几秒,要补镜头与变化)。4–6 秒的短镜可以只给一个动作 + 一个镜头语言,但仍要写清景别。注意:这是给生图/视频导演的画面,**不占 narration 的字数额度**。 - 不要输出 schema 之外的字段,也不要省略必填字段。 ### 铁律 2 · 输出前自检 @@ -93,7 +106,8 @@ description: > 详见 `references/methodology.md`「旁白红线」。最关键的几条: - **口语化**,像真人对着镜头说话,不准书面腔 / AI 腔。 -- **每镜 narration ≤ 55 字**(Seedance 15 秒内直接发声,字数 = 可懂语速上限)。 +- **每镜 narration ≤ `duration × 3.5` 字,且绝不超过 55 字**(出片模型在镜内直接发声, + 3.5 字/秒是可懂语速上限)。短镜装不下就拆到下一镜,或干脆留空走纯画面。 - **禁违规词**:医疗功效(治疗/根治/抗癌…)、绝对化用语(最/第一/100%/国家级…)一律不写。 - 不浮夸、不空喊,卖点要落到「商品怎么解决痛点」。 @@ -110,10 +124,12 @@ description: > | 参数 | 值 | | ---- | ---- | | 画幅 | **默认 9:16 竖屏**,由输入可覆盖(16:9 / 1:1 / 4:5 等照用) | -| 总时长 | **15 / 30 / 60 / 90 秒四档**,由输入给定;未指定时默认 60 | -| 分镜 | 每 15 秒一镜,均分(粗暴切,不做复杂算法)→ 1 / 2 / 4 / 6 镜 | -| 镜头功能 | 钩子 → 痛点 → 卖点 → CTA(黄金结构;不同档位按映射表压缩/扩展,见方法论) | -| 发声方式 | Seedance 直接生成画面+音效+人声(**不走 TTS**) | +| 总时长 | **5–60 秒,5 秒步进**,由输入给定;未指定时默认 30 | +| 表现形式 | **口播 / 短剧 / Vlog**,由输入给定;未指定时按品类人群自选 | +| 视频结构 | **痛点解决 / 前后对比 / 测评验证 / 场景种草**,由输入给定;未指定时按品类人群自选 | +| 分镜 | **单镜 4–15 秒,可不等长**;镜数按表现形式的推荐节奏定(见方法论) | +| 镜头功能 | 钩子 → 痛点 → 卖点 → CTA(黄金结构;具体骨架以视频结构套路为准) | +| 发声方式 | 出片模型直接生成画面+音效+人声(**不走 TTS**) | --- @@ -123,23 +139,38 @@ description: > | 模式 | 触发条件 | 处理逻辑 | 需读取 | | ---- | ---- | ---- | ---- | -| **① 全自动** | 用户只给【商品信息 + 前置条件(调性/平台/时长/卖点勾选)】,无主题无原稿 | 凭商品与前置条件,自动定档、选 tone、造 entity、按映射填镜 | `methodology.md` + `hook-library.md` + `category-playbook.md` + `platform-tone.md` | +| **① 全自动** | 用户只给【商品信息 + 前置条件(表现形式/视频结构/时长/人群/卖点勾选)】,无主题无原稿 | 凭商品与前置条件,自动定镜数、选 tone、造 entity、按套路填镜 | `methodology.md` + `hook-library.md` + `category-playbook.md` + `platform-tone.md` | | **② 一句话** | 用户额外给了一句主题(如「主打熬夜党救星」) | 以该主题为脚本主轴扩写,其余同全自动 | 同上(主题优先于自动选题) | -| **③ 改稿** | 用户给了已有脚本/文案 | **保留用户原意**,只增强钩子/节奏/卖点/CTA,并归一化到 JSON 结构 | `methodology.md` + `hook-library.md` + `checklist.md` | +| **③ 改稿 / 上传脚本** | 用户给了已有脚本/文案 | **保留用户原意**,先识别原稿结构与叙述顺序,再增强钩子/节奏/卖点证明/CTA,并归一化到 JSON 结构 | `methodology.md` + `hook-library.md` + `checklist.md` | | 信息不足 | 连商品信息都缺,无法生成 | 唯一允许的回退:用一句话问清最少必要信息 | — | **进入任何模式前,必须先读取该行列出的参考资料。** +### 套路资料(三种模式都适用) + +除上表外,**每次生成都必须读** `references/playbooks/combo-matrix.md`, +并根据输入指定的表现形式与视频结构,**各读一份**: + +- `references/playbooks/format-{口播→oral | 短剧→drama | Vlog→vlog}.md` +- `references/playbooks/structure-{痛点解决→pain | 前后对比→contrast | 测评验证→review | 场景种草→scene}.md` + +> 运行时后端只会把被指定的那两份套路拼进上下文。**看到哪份就用哪份, +> 不要凭记忆套用没加载进来的套路。** +> 套路里的结构骨架、镜头语言、发声方式、红线,**优先级高于本文件的通用默认值**。 + --- ## 生成流程(内部执行,一次走完,不暂停) -1. **路由** — 判定输入模式(①/②/③),加载对应 references。 -2. **定档** — 从输入读取画幅(`aspect_ratio` 默认 9:16)与时长档位(15/30/60/90,未指定默认 60),算出 `segment_count = total_duration / 15`。 -3. **定调(tone)** — 依据品类话术 + 平台调性 + 前置条件,选定 `tone`;②③ 模式尊重用户已表达的倾向。 +1. **路由** — 判定输入模式(①/②/③),加载对应 references;再按输入的表现形式与视频结构加载那两份套路。 +2. **定镜** — 读画幅(`aspect_ratio` 默认 9:16)与总时长(5–60 秒,未指定默认 30); + 按表现形式的推荐单镜节奏定出镜数,再给每镜分配 4–15 秒的时长,**确保加总等于总时长**。 +3. **定调(tone)** — 依据视频结构 + 品类话术 + 平台调性,选定 `tone`;②③ 模式尊重用户已表达的倾向。 4. **抽取/创建 entities** — 识别脚本需要的角色 / 场景 / 商品;为每个 entity 写一份**全脚本共用**的 `visual_prompt`(保证多镜同一角色同一张脸);可选写 `voice_ref` 锁音色。 -5. **按档位填黄金结构** — 依「档位 × 黄金结构映射表」给每个 segment 分配 `role`;钩子镜套用 `hook-library.md` 的公式。 -6. **写 narration / visual / 商品露出** — 每镜旁白 ≤55 字、口语化、过红线;每镜规划自然的 `product_exposure`。 +5. **按套路填结构** — 优先用视频结构套路里的骨架给每个 segment 分配 `role`; + 套路没覆盖到的用「role 按镜数分配」表兜底;钩子镜套用该套路指定的钩子写法。 +6. **写 narration / visual / 商品露出** — 每镜旁白 ≤ `duration × 3.5` 字、口语化、过红线; + 发声方式按表现形式套路来;每镜规划自然的 `product_exposure`。 7. **连引用** — 填 `entity_refs` 与 `speaker`,确认每个 entity 都被引用、id 都合法。 8. **自检** — 跑 `checklist.md`,过了再输出。 9. **输出** — 仅输出铁律 1 的 JSON。 @@ -150,7 +181,10 @@ description: > | 文件 | 内容 | 何时读取 | | ---- | ---- | ---- | -| `references/methodology.md` | 黄金结构模板(钩子→痛点→卖点→CTA)、档位 × 结构映射表、entity 一致性原则、商品露出规范、旁白红线(含违规词清单) | **每次生成都读** | +| `references/playbooks/combo-matrix.md` | 表现形式 × 视频结构 合法组合表、各组合交叉点要点、结构最短可用时长 | **每次生成都读** | +| `references/playbooks/format-*.md` | 单份表现形式套路:人物设置、镜头语言、单镜节奏、发声方式、专属红线 | **读被指定的那一份** | +| `references/playbooks/structure-*.md` | 单份视频结构套路:结构骨架、每段功能与判断标准、钩子写法、专属红线 | **读被指定的那一份** | +| `references/methodology.md` | 黄金结构模板(钩子→痛点→卖点→CTA)、时长 → 镜数映射、entity 一致性原则、商品露出规范、旁白红线(含违规词清单) | **每次生成都读** | | `references/hook-library.md` | 前 3 秒钩子公式库(痛点提问 / 反差 / 数字冲击 / 身份代入 …,含例句) | 每次生成都读(写钩子镜时) | | `references/category-playbook.md` | 分品类话术(美妆 / 食品 / 3C / 服饰 / 家居…的语气与卖点侧重) | 全自动 / 一句话模式 | | `references/platform-tone.md` | 平台调性(抖音 / 快手 / 小红书 / 视频号 的节奏与风格差异) | 全自动 / 一句话模式 | diff --git a/core/backend/skills/ecommerce-video-script/references/checklist.md b/core/backend/skills/ecommerce-video-script/references/checklist.md index b8158a7..be9fdc0 100644 --- a/core/backend/skills/ecommerce-video-script/references/checklist.md +++ b/core/backend/skills/ecommerce-video-script/references/checklist.md @@ -10,9 +10,14 @@ - [ ] 最终只输出**一个** JSON 对象,无 schema 外的多余文字/注释。 - [ ] `tone` ∈ `{种草, 测评, 剧情, 痛点}`。 - [ ] `aspect_ratio` 存在;输入指定了就用输入值,未指定为 `"9:16"`。 -- [ ] `total_duration` ∈ `{15, 30, 60, 90}`。 -- [ ] `segment_count == total_duration / 15`,且 `segments` 数组长度等于它。 -- [ ] 每个 `segment.duration == 15`;`index` 从 0 连续递增无跳号。 +- [ ] `presentation_format` ∈ `{口播, 短剧, Vlog}`,且与输入一致(输入没给则自选并如实填写)。 +- [ ] `video_structure` ∈ `{痛点解决, 前后对比, 测评验证, 场景种草}`,同上。 +- [ ] **组合合法**:不是「短剧 × 测评验证」(唯一禁用组合,见 `playbooks/combo-matrix.md`)。 +- [ ] `total_duration` 是 **5–60 之间 5 的倍数**。 +- [ ] **每个 `segment.duration` 是 4–15 之间的整数**(15 是下游出片硬上限,越界直接出不了片)。 +- [ ] **`sum(segments[].duration) == total_duration`**,逐个加一遍,差一秒都不行。 +- [ ] `segment_count == len(segments)`;`index` 从 0 连续递增无跳号。 +- [ ] 镜数落在 `ceil(total_duration/15)` 到 `floor(total_duration/4)` 区间内。 - [ ] 每个 `segment.role` ∈ `{钩子, 痛点, 卖点, CTA}`。 - [ ] 首镜 `role == 钩子`;末镜收 CTA(独立 CTA 镜,或末镜旁白末尾含明确行动指令)。 - [ ] `entities` 每项 `type` ∈ `{character, scene, product}`,`id` 全局唯一。 @@ -20,7 +25,7 @@ - [ ] **每个声明的 entity 至少被一个 segment 引用**(无孤儿 entity)。 - [ ] **至少声明 1 个 `type==scene` 实体;每个 segment 的 `entity_refs` 恰好引用一个 scene**(同环境复用同一 scene id,换环境才新建;产品特写镜也要绑所处环境的 scene)。 - [ ] `speaker` 要么为 `null`,要么指向一个 `type==character` 的 id。 -- [ ] 必填字段无缺失:顶层 `hook/tone/aspect_ratio/total_duration/segment_count/entities/segments`; +- [ ] 必填字段无缺失:顶层 `hook/tone/presentation_format/video_structure/aspect_ratio/total_duration/segment_count/entities/segments`; 每 entity 有 `id/type/name/visual_prompt/ref_index`; 每 segment 有 `index/duration/role/narration/visual/product_exposure/entity_refs`。 @@ -29,17 +34,22 @@ - [ ] 钩子镜第一句在前 3 秒抛出钩子,套用了 `hook-library.md` 的某个公式。 - [ ] 顶层 `hook` 与钩子镜口径一致。 - [ ] 卖点镜的卖点**挂在前面铺的痛点上**,不是干罗列参数。 -- [ ] CTA 给了明确动作(点小黄车/领券/主页链接)。 -- [ ] 档位映射正确:role 序列符合 `methodology.md`「档位 × 黄金结构映射」表。 -- [ ] 90s 的多个卖点镜**各有侧重不重复**。 +- [ ] CTA 给了明确动作(点小黄车/领券/主页链接)。**场景种草结构的 CTA 要软**,别硬喊。 +- [ ] **结构骨架走完了**:段的顺序与功能符合 `playbooks/structure-*.md` 里那份骨架表。 +- [ ] **表现形式的镜头语言到位**:景别变化、单镜时长、人物数量符合 `playbooks/format-*.md`。 +- [ ] role 序列符合结构套路;套路没覆盖的部分回落到 `methodology.md`「role 按镜数分配」表。 +- [ ] 多个卖点镜**各有侧重不重复**。 +- [ ] **逐条过了这两份套路各自的「专属红线」**,一条都没踩。 - [ ] 同一角色/场景/商品全程复用同一 entity(没有给同一对象写出两份 visual_prompt)。 - [ ] 每个 `visual_prompt` 信息足够喂图模型(角色/场景/商品的外观特征写清)。 - [ ] 每镜 `product_exposure` 自然、与 role 匹配(参考方法论露出表)。 ## C. 旁白红线扫描(逐镜) -- [ ] 每镜 `narration` ≤ 55 字(硬上限;目标 ≤50 留缓冲,逐字数一遍,别凭感觉)。 -- [ ] 每镜 `visual` ≥ 40 字且含「景别/运镜 + 一个画面变化」,不是一句静态动作(撑满 15 秒)。 +- [ ] **每镜 `narration` ≤ `duration × 3.5` 字,且绝不超过 55 字**(逐镜按自己的秒数数一遍,别凭感觉。 + 4 秒镜 ≤14 字、8 秒镜 ≤28 字、15 秒镜 ≤52 字,对照表见 `methodology.md`)。 +- [ ] 每镜 `visual` 含「景别/运镜 + 一个画面变化」,不是一句静态动作;能撑满这一镜的秒数 + (≥8 秒的镜约 40–70 字;4–6 秒的短镜可以更短,但景别必须写清)。 - [ ] 口语化,无书面腔/AI 腔("综上""不仅…而且""值得一提"等已清除)。 - [ ] **违规词扫描**:无医疗功效词(治疗/根治/抗癌/消炎/排毒/速效…)。 - [ ] **绝对化用语扫描**:无 最/第一/唯一/100%/国家级/永久/绝对/史上 等。 diff --git a/core/backend/skills/ecommerce-video-script/references/methodology.md b/core/backend/skills/ecommerce-video-script/references/methodology.md index 9f590e0..c4bb6ae 100644 --- a/core/backend/skills/ecommerce-video-script/references/methodology.md +++ b/core/backend/skills/ecommerce-video-script/references/methodology.md @@ -22,22 +22,50 @@ --- -## 二、档位 × 黄金结构映射(关键) +## 二、时长 → 镜数 → 黄金结构映射(关键) -时长由输入给定(15/30/60/90 四档),每 15 秒一镜,`segment_count = total_duration / 15`。 -镜数变了,四个功能要**压缩或扩展**,按下表分配 `role`: +### 2.1 时长规则(不再是固定四档,也不再每镜等长) -| 档位 | 镜数 | role 序列(index 0→N) | 压缩/扩展说明 | -| ---- | ---- | ---- | ---- | -| **15s** | 1 | `[钩子]` | 一镜到底:开头 3 秒钩子 → 中段一句卖点 → 末尾一句 CTA,全压进这 15 秒旁白里 | -| **30s** | 2 | `[钩子, 卖点]` | 镜0 钩子里带出痛点;镜1 讲卖点并在旁白末尾收一句 CTA | -| **60s** | 4 | `[钩子, 痛点, 卖点, CTA]` | 标准黄金结构,一镜一功能 | -| **90s** | 6 | `[钩子, 痛点, 卖点, 卖点, 卖点, CTA]` | 痛点后给 3 个卖点镜:核心卖点 / 场景化演示 / 信任背书(口碑·数据);末镜 CTA | +- **总时长**由输入给定:**5–60 秒之间、5 秒一档**(5/10/15/…/60)。未指定时默认 30。 +- **单镜时长 4–15 秒**,这是下游出片模型的硬上限,**任何一镜都不许超过 15 秒、不许低于 4 秒**。 +- **每镜时长可以不一样。** 该长的镜(讲卖点、举证)就给足,该短的镜(空镜、转场、一句钩子)就压短。 + 别再机械均分。 +- 所有 `segments[].duration` 相加**必须精确等于** `total_duration`。 + +### 2.2 镜数怎么定 + +镜数不是算出来的,是**按表现形式的节奏定**的。先取该形式的推荐单镜时长,再用总时长去除: + +| 表现形式 | 推荐单镜时长 | 理由 | +| ---- | ---- | ---- | +| **口播** | 10–15 秒 | 说完一个完整意思需要时间,切太碎话会断 | +| **短剧** | 6–10 秒 | 靠切换制造节奏,镜头长了拖沓 | +| **Vlog** | 5–8 秒 | 碎片拼贴感,镜头短、数量多 | + +`segment_count ≈ total_duration / 推荐单镜时长`,向最接近的整数取整, +然后**必须**落在 `ceil(total_duration/15)` 到 `floor(total_duration/4)` 这个区间内。 + +例:口播 30 秒 → 30/12 ≈ 2.5 → 3 镜(如 12+10+8)。 +  短剧 45 秒 → 45/8 ≈ 5.6 → 6 镜(如 8+7+9+7+8+6)。 +  Vlog 30 秒 → 30/6.5 ≈ 4.6 → 5 镜(如 6+6+7+6+5)。 + +### 2.3 role 按镜数分配 + +| 镜数 | role 序列(index 0→N) | 说明 | +| ---- | ---- | ---- | +| **1** | `[钩子]` | 一镜到底:钩子 → 一句卖点 → 一句 CTA,全压进这一镜的旁白 | +| **2** | `[钩子, 卖点]` | 镜0 钩子里带出痛点;镜1 讲卖点并在末尾收 CTA | +| **3** | `[钩子, 卖点, CTA]` | 痛点并进钩子镜 | +| **4** | `[钩子, 痛点, 卖点, CTA]` | 标准黄金结构,一镜一功能 | +| **5** | `[钩子, 痛点, 卖点, 卖点, CTA]` | 两个卖点镜要各有侧重 | +| **N ≥ 4** | `[钩子, 痛点, 卖点 × (N-3), CTA]` | 通用规则:首钩子、次痛点、末 CTA,中间全是卖点 | 说明: -- `role` 字段只能取 `钩子|痛点|卖点|CTA` 四个枚举值。"信任背书""场景演示"等都归入 `卖点`。 -- 短档位(15/30s)靠**旁白内压缩多功能**达成"留人+转化",不要因为镜少就丢掉 CTA。 -- 90s 的 3 个卖点镜要**各有侧重、不重复**(核心功效 / 真实使用场景 / 别人为什么买)。 +- `role` 只能取 `钩子|痛点|卖点|CTA` 四个枚举值。"信任背书""场景演示""前后对比"等都归入 `卖点`。 +- **镜少也不许丢 CTA** —— 镜数 ≤2 时把 CTA 压进末镜旁白的最后一句。 +- 多个卖点镜要**各有侧重、不重复**(核心功效 / 真实使用场景 / 别人为什么买 / 举证读数)。 +- 视频结构会覆盖这里的默认分配 —— 以 `playbooks/structure-*.md` 里的骨架为准, + 本表只是没有指定结构时的兜底。 --- @@ -74,8 +102,22 @@ ## 五、旁白红线(硬规则,违反即不合格) - **口语化**:像真人对着镜头唠嗑,不准书面腔 / 不准 AI 腔("综上所述""不仅…而且""值得一提的是"全禁)。 -- **每镜 `narration` ≤ 55 字**:Seedance 在 15 秒内直接发声,55 字是可懂语速**硬上限**。 - **写作目标 ≤ 50 字**,留 5 字缓冲——宁可短、不要卡满;短档位(15/30s)一镜要装多功能时尤其要狠删,先保 CTA 不被砍。 +- **`narration` 字数按这一镜的秒数算**:出片模型在镜内直接发声,可懂语速上限约 **3.5 字 / 秒**。 + **每镜 `narration` ≤ `duration × 3.5` 字,且任何情况下不超过 55 字。** + 对照表(写作目标再留 10% 缓冲,宁可短不要卡满): + + | 单镜时长 | 字数上限 | 写作目标 | + | ---- | ---- | ---- | + | 4 秒 | 14 字 | ≤ 12 字 | + | 5 秒 | 17 字 | ≤ 15 字 | + | 6 秒 | 21 字 | ≤ 19 字 | + | 8 秒 | 28 字 | ≤ 25 字 | + | 10 秒 | 35 字 | ≤ 31 字 | + | 12 秒 | 42 字 | ≤ 38 字 | + | 15 秒 | 52 字 | ≤ 47 字 | + + **短镜不要硬塞长句** —— 装不下就把这句拆到下一镜,或者干脆让这镜没有旁白(纯画面)。 + 总时长很短、一镜要装多功能时尤其要狠删,先保 CTA 不被砍。 - **不浮夸、不空喊**:卖点要给具体理由,不堆形容词。 - **违规词禁令**(电商广告法红线,一律不写): - **医疗功效类**:治疗 / 根治 / 疗效 / 抗癌 / 消炎 / 杀菌(无证) / 排毒 / 速效 / 抑制 ××病 … diff --git a/core/backend/skills/ecommerce-video-script/references/playbooks/combo-matrix.md b/core/backend/skills/ecommerce-video-script/references/playbooks/combo-matrix.md new file mode 100644 index 0000000..067f441 --- /dev/null +++ b/core/backend/skills/ecommerce-video-script/references/playbooks/combo-matrix.md @@ -0,0 +1,108 @@ +# 表现形式 × 视频结构 · 组合矩阵 + +> 表现形式决定**怎么拍**(谁在画面里、镜头怎么动、谁在说话)。 +> 视频结构决定**怎么说服**(信息按什么顺序给、靠什么建立可信)。 +> 两者正交:同一个结构用三种形式拍出来完全不同,同一种形式套四个结构说服力也不同。 + +**加载规则:** 本文件每次都读。另外只读输入指定的那一份 `format-*.md` 和一份 `structure-*.md`, +不要凭记忆套用没加载的套路。 + +--- + +## 一、合法组合表 + +| | 痛点解决 | 前后对比 | 测评验证 | 场景种草 | +| ---- | :----: | :----: | :----: | :----: | +| **口播** | ✅ 强 | ✅ 可 | ✅ **最强** | ✅ 可 | +| **短剧** | ✅ **最强** | ✅ 强 | ❌ **禁用** | ✅ 可 | +| **Vlog** | ✅ 可 | ✅ 可 | ✅ 强 | ✅ **最强** | + +**唯一禁用组合:短剧 × 测评验证。** +理由:测评的说服力来自「这是真实发生的」,短剧的本质是「这是演的」。 +两者直接冲突 —— 演出来的实测读数没有任何可信度,反而会让观众怀疑品牌。 +输入若指定了这个组合,按「短剧 × 痛点解决」处理,并在 `hook` 里保持剧情感。 + +--- + +## 二、各组合要点(只看你被指定的那一格) + +### 口播 × 痛点解决 +最直给的一条。人在画面里直接把痛点说出来,靠**共犯身份**建立信任。 +- 钩子用「我们这种___的人都懂」,第一句就把观众归类进来 +- 讲卖点那镜必须切商品特写,手要入画 +- 全片一个人一个场景,靠景别变化撑节奏 + +### 口播 × 前后对比 +口播里唯一画面重于台词的组合。人要**退到画外**让位给对比画面。 +- 「之前 / 之后」两镜人不出镜或只出手,避免抢戏 +- 人只在开头(交代条件)和结尾(收 CTA)出镜 +- 台词负责讲清「同机位、同光线、几天」,画面负责证明 + +### 口播 × 测评验证 ★ 最强组合 +带货转化率最高的组合,尤其适合中高客单价。 +- 人的**身份权威性**要在前 3 秒立住(从业年限 / 专业背景 / 测过多少款) +- 读数特写时人可以出画外,但声音要连着不断 +- 必须说一个缺点,且要从「专业判断」的角度说,不是随口一句 + +### 口播 × 场景种草 +最弱的一格,因为口播是「对镜说话」,天然打断场景沉浸。 +- **必须弱化对镜**:人在场景里自然活动,偶尔看向镜头,不要全程盯着 +- 台词改成自言自语的语气,不要介绍腔 +- 如果商品本身氛围属性强(香氛、饮品),优先考虑改用 Vlog + +### 短剧 × 痛点解决 ★ 最强组合 +完播率和转化率兼顾的组合,短剧的天然主场。 +- 第一幕直接**演**痛点,不用台词解释(这比口播讲痛点强十倍) +- 副角在转折点带出商品,主角不要自夸 +- 第三幕的「结果」就是痛点解决后的同一场景,形成闭环 + +### 短剧 × 前后对比 +把对比拆进剧情里,用**两幕**承载前后。 +- 前后两幕必须同场景同机位,只有主角状态变了 +- 中间用一个短镜交代使用过程,否则反转不可信 +- 台词极简,让画面对比自己说话 + +### 短剧 × 场景种草 +剧情化的生活切片,广告感最低。 +- 冲突要**很轻**(不是麻烦,是一个小小的期待) +- 商品是场景里的一个自然道具,不承担解决问题的功能 +- 结尾不要强反转,停在氛围里就好 + +### Vlog × 痛点解决 +用生活流水记录痛点,比口播柔和,比短剧真实。 +- 痛点出现在时间线上的某个节点(「下午三点,又开始了」) +- 商品在下一个节点自然出现,不做介绍 +- CTA 要软,硬推会破坏 Vlog 的可信度 + +### Vlog × 前后对比 +用**时间跨度**做对比,Vlog 天然适合记录「第 1 天 / 第 7 天」。 +- 每个时间点用同机位拍一次,形成序列 +- 画面上标时间(Day 1 / Day 7),这是 Vlog 里唯一允许的字幕硬信息 +- 中间的日常镜头就是「过程可见」,可信度天然高 + +### Vlog × 测评验证 +生活化实测,比正经测评亲和,但证据强度要守住。 +- 测的过程放进真实生活场景(在自家厨房测,不在摄影棚) +- 读数依然要给特写、要看得清,这条不能因为是 Vlog 就放松 +- 结论用自述语气给(「反正我是回购了,但它确实有点贵」) + +### Vlog × 场景种草 ★ 最强组合 +氛围感最强的组合,适合食品饮料、家居、香氛、户外。 +- 全片可以没有一句参数,纯靠光、声音、细节 +- 商品在两到三个时间节点重复出现,靠**重复**建立记忆 +- 至少一个镜头让商品外形被看清,这是本组合唯一的硬要求 + +--- + +## 三、组合与时长的关系 + +表现形式给出推荐时长,视频结构决定能压到多短: + +| 结构 | 最短可用 | 原因 | +| ---- | ---- | ---- | +| 痛点解决 | 15 秒 | 痛点一句、卖点一句、CTA 一句,勉强够 | +| 前后对比 | 10 秒 | 前后两镜就能成立,最抗压缩 | +| 测评验证 | 20 秒 | 举证需要时间,低于 20 秒证据不足 | +| 场景种草 | 20 秒 | 氛围需要铺陈,太短像随机片段 | + +低于该结构的最短时长时,优先保住「钩子 + 结构核心 + CTA」三件,其余全砍。 diff --git a/core/backend/skills/ecommerce-video-script/references/playbooks/format-drama.md b/core/backend/skills/ecommerce-video-script/references/playbooks/format-drama.md new file mode 100644 index 0000000..57aa88e --- /dev/null +++ b/core/backend/skills/ecommerce-video-script/references/playbooks/format-drama.md @@ -0,0 +1,81 @@ +# 表现形式 · 短剧 + +> 有角色、有冲突、有转折的微型情节剧。观众先被故事勾住,商品在转折点出场。 +> 完播率最高的形式,但**商品露出最容易被故事盖掉**——这是短剧唯一的死穴。 + +--- + +## 一、本质 + +短剧卖的是**代入感**:观众在主角身上看到自己,于是相信主角的选择。 +所以短剧的商品不能靠「介绍」,只能靠「解决」——它必须是剧情里那个**转折的原因**。 + +**判断标准:** 把商品从剧情里拿掉,如果故事依然能讲通,那这条短剧就白拍了。 +商品必须是不可替换的转折点。 + +--- + +## 二、人物设置 + +- **两到三个角色,不能更多。** 短视频装不下第四个人,观众记不住。 +- 标准配置是**一主一副**: + - **主角** = 有困扰的人(观众的镜子)—— 戏份最重,情绪弧线在他身上 + - **副角** = 推动者(闺蜜 / 同事 / 家人 / 店员)—— 负责把商品带进来 +- 让**副角**说出商品,别让主角自卖自夸。第三方推荐的可信度远高于自述。 +- 每个角色在 `entities` 里独立声明,`visual_prompt` 写清外形与穿着,全片锁脸。 + +--- + +## 三、三幕结构(必须走完) + +短剧再短也要有完整的起承转合,否则就是没头没尾的片段。 + +| 幕 | 占比 | 内容 | 必须做到 | +| ---- | ---- | ---- | ---- | +| **第一幕 · 困境** | 前 30% | 主角遇到具体的麻烦,情绪是负面的 | 麻烦要**具体可见**,不能是抽象的「我很累」 | +| **第二幕 · 转折** | 中间 40% | 副角带出商品,主角将信将疑到尝试 | **商品在这里第一次出现**,且要被手拿着看清 | +| **第三幕 · 结果** | 后 30% | 主角状态反转,情绪变正面 | 反转要**看得见**,画面上的变化比台词重要 | + +**转折点必须在整片的 1/3 处。** 商品出现太晚(超过一半时长)观众已经划走了。 + +--- + +## 四、镜头语言 + +- **对话镜用中景**(能看到两个人的关系和肢体) +- **情绪镜用近景**(主角的表情就是共鸣点) +- **商品出场必须给一个特写**,哪怕只有 2 秒 +- 场景可以有两个(如「工位」→「家里」),但不要超过两个,换太多观众会晕 + +--- + +## 五、节奏与单镜时长 + +- **单镜 6–10 秒**(短剧靠切换制造节奏,镜头长了就拖) +- 一镜一个动作或一次对话交换,**别在一镜里塞完整对话** +- 有对白的镜,每句 `line` ≤ 25 字(两个人说话,总量还是受 55 字限制) + +**推荐总时长 45 秒。** 短剧需要装下三幕,低于 30 秒会讲不完整; +超过 60 秒在电商场景下性价比开始下降。 + +--- + +## 六、发声方式 + +短剧是**唯一必须用 `dialogue` 的形式**。 + +- `dialogue` 填角色对白:`[{"speaker":"c1","line":"..."},{"speaker":"c2","line":"..."}]` +- `narration` 把各句 `line` 拼起来兜底(下游字幕和配音要用) +- 允许个别镜是纯画面(`narration` 和 `dialogue` 都空),用来给情绪留白 +- **别每一镜都塞满对白** —— 有沉默的镜,情绪才有落点 + +--- + +## 七、短剧专属红线 + +- ❌ **商品在最后才出现** —— 转折点在 1/3 处,不是结尾 +- ❌ **角色超过三个** —— 观众记不住 +- ❌ **主角自己夸商品** —— 让副角说,或让结果说 +- ❌ **冲突是抽象的** —— 「最近好烦」不算冲突,「客户又打回来第五版」才算 +- ❌ **结局只靠台词交代** —— 反转必须在画面上看得见 +- ❌ **把测评做成短剧** —— 演出来的测评没有可信度,这个组合是禁用的 diff --git a/core/backend/skills/ecommerce-video-script/references/playbooks/format-oral.md b/core/backend/skills/ecommerce-video-script/references/playbooks/format-oral.md new file mode 100644 index 0000000..144861f --- /dev/null +++ b/core/backend/skills/ecommerce-video-script/references/playbooks/format-oral.md @@ -0,0 +1,75 @@ +# 表现形式 · 口播 + +> 一个人对着镜头说话。观众看的是「这个人可信不可信」,不是「这个故事好不好看」。 +> 电商带货里最稳、转化最直接的形式。信息密度最高,制作最省。 + +--- + +## 一、本质 + +口播的全部说服力压在**说话的人**身上。画面只是背书,台词才是主体。 +所以口播脚本的重心是:**每一句话都要有信息**,不能有一句是废话铺垫。 + +**判断标准:** 把画面全遮住只听声音,如果这条视频依然成立,那口播就写对了。 + +--- + +## 二、人物设置 + +- **只要一个人。** 口播不需要第二个角色。真的需要对话就别选口播,选短剧。 +- 这个人必须有一个**明确身份**,且身份要跟商品可信度挂钩: + - 用过的人(真实用户)→ 讲体验 + - 懂行的人(成分党 / 从业者 / 测评师)→ 讲原理 + - 同类人(同龄 / 同职业 / 同困扰)→ 讲共鸣 +- 身份在**第一镜前 3 秒内交代完**,用一句话,别铺垫。 + - ✅「我做了六年护肤研发,这瓶我拆开跟你讲。」 + - ❌「大家好,欢迎来到我的频道,今天想跟大家聊一个话题。」 + +`entities` 里这个人是唯一的 `type:"character"`,全片复用同一 `visual_prompt`。 + +--- + +## 三、镜头语言 + +口播的画面变化不靠剧情,靠**景别切换**和**手部动作**。同一个人同一个场景, +连续 30 秒不换机位会让人划走。 + +| 镜头功能 | 景别 | 用法 | +| ---- | ---- | ---- | +| 开场钩子 | 近景(胸上) | 眼睛对镜头,情绪最强的一句话 | +| 讲痛点 | 中近景 | 可以有手势,身体略前倾 | +| 讲卖点 | 特写 / 手持商品 | **必须切到商品**,手入画,让观众看清 | +| 演示 | 商品特写(人出画外) | 只拍手和商品,人声继续 | +| 收 CTA | 近景回到人 | 眼睛回到镜头,语速放慢 | + +**铁律:讲卖点的那一镜,商品必须实体出现在画面里被手拿着。** 光靠嘴说卖点、 +画面还是大头,是口播最常见的失败。 + +--- + +## 四、节奏与单镜时长 + +- **单镜 10–15 秒**(口播里说完一个完整意思需要时间,切太碎会让话说不完整) +- 一句话讲一件事,**一镜讲一到两句话** +- 语速按 **3.5 字 / 秒** 估:12 秒的镜 ≈ 42 字,别超 + +**推荐总时长 30 秒。** 口播超过 45 秒完播率掉得很快,除非是测评验证这种 +需要举证的结构。 + +--- + +## 五、发声方式 + +- `narration` 全填,`dialogue` **永远留空 `[]`**(只有一个人,没有对话) +- `speaker` 指向那个 character 的 id(不是画外音,是这个人在说) +- 口播不用画外旁白 —— 人就在画面里,再加一层旁白会精神分裂 + +--- + +## 六、口播专属红线 + +- ❌ **开场自我介绍 + 寒暄** —— 前 3 秒必须是钩子,不是「大家好」 +- ❌ **一个机位怼到底** —— 至少 3 次景别变化 +- ❌ **念稿腔** —— 短句、口语、允许有语气词(「说真的」「你听我讲完」) +- ❌ **多个人轮流说** —— 那是短剧或访谈,不是口播 +- ❌ **台词写成书面语** —— 「其具备优异的保湿性能」→「它是真的锁水,我这脸一天不干」 diff --git a/core/backend/skills/ecommerce-video-script/references/playbooks/format-vlog.md b/core/backend/skills/ecommerce-video-script/references/playbooks/format-vlog.md new file mode 100644 index 0000000..0ec6671 --- /dev/null +++ b/core/backend/skills/ecommerce-video-script/references/playbooks/format-vlog.md @@ -0,0 +1,85 @@ +# 表现形式 · Vlog + +> 第一人称的生活记录。镜头是「我的眼睛」,观众跟着我过一段生活,商品自然地出现在里面。 +> 广告感最低的形式,适合高频消费品和生活方式类商品。 + +--- + +## 一、本质 + +Vlog 卖的是**生活方式**,不是商品参数。观众想要的是「我也想过这样的日子」, +商品是这种生活的一部分,顺手被带走。 + +**判断标准:** 如果观众看完记住的是「这个人的生活真好」而不是「这个商品参数真好」, +但依然想买,那 Vlog 就写对了。**Vlog 不许硬推。** + +--- + +## 二、人物设置 + +- **一个第一人称主角**,通常出镜但不总是对着镜头说话 +- 主角可以是**半出镜**的:只有手、只有背影、只有侧脸 —— 这反而更有代入感 +- 可以有配角,但配角是**生活的一部分**(室友、同事、宠物),不承担推销功能 +- 主角身份靠**生活细节**建立,不靠自我介绍: + - ✅ 画面里是加班到十点的工位、外卖盒、屏幕上的表格 + - ❌ 台词说「我是一个每天加班的上班族」 + +--- + +## 三、时间线结构 + +Vlog 靠**时间推进**组织,不靠论证推进。这是它跟口播、短剧最大的区别。 + +常用时间骨架(选一条,别混): + +| 骨架 | 适合品类 | 节点示例 | +| ---- | ---- | ---- | +| **一天** | 日用 / 护肤 / 食品 / 咖啡 | 早上起床 → 出门 → 午间 → 下班 → 睡前 | +| **一次外出** | 服饰 / 箱包 / 户外 / 相机 | 出门前准备 → 路上 → 到达 → 回程 | +| **一件事** | 厨具 / 家居 / 工具 | 决定做 → 准备 → 过程 → 完成 | + +**商品出现在某个时间节点上,作为「这个时刻我在用它」**,不单独开一段介绍。 + +--- + +## 四、镜头语言 + +Vlog 的镜头必须有**手持感和生活质感**,这是它的可信度来源。 + +- **手持跟拍**为主,允许轻微晃动 +- 大量**空镜和细节镜**:窗外的光、杯子上的水汽、桌面一角 —— 这些是 Vlog 的质感 +- 商品**不给硬广式特写**,给「使用中的手部特写」 +- 光线尽量自然光,时间感要对(早上是冷白光,傍晚是暖黄光) + +--- + +## 五、节奏与单镜时长 + +- **单镜 5–8 秒**(Vlog 是碎片拼贴,镜头短、数量多) +- 允许有 4–5 秒的纯空镜,用来换时间段 +- 不需要每镜都说话,**画面自己会讲** + +**推荐总时长 30 秒。** Vlog 靠氛围,时长太短装不下时间感(低于 20 秒会像随机片段), +太长又超出电商场景的耐心。 + +--- + +## 六、发声方式 + +- 以**画外旁白**为主:`narration` 填,`speaker` 设为 `null`(是「我」在回想,不是对镜头说) +- `dialogue` 基本不用;只有真实生活对话才填(如跟室友的一句闲聊) +- **允许整镜无声**(`narration` 和 `dialogue` 都空),纯画面 + 环境音 +- 旁白语气是**自言自语**,不是介绍: + - ✅「这杯是今天唯一的清醒时刻。」 + - ❌「这款咖啡采用精品阿拉比卡豆,风味层次丰富。」 + +--- + +## 七、Vlog 专属红线 + +- ❌ **中途切换成口播** —— 突然对着镜头开始介绍商品,Vlog 的气就泄了 +- ❌ **给商品硬广特写** —— 商品只在「被使用」时出现 +- ❌ **旁白讲参数** —— 参数是口播和测评的活,Vlog 只讲感受 +- ❌ **时间线跳跃混乱** —— 时间必须单向推进,不许倒叙 +- ❌ **强行喊 CTA** —— Vlog 的 CTA 要软(「链接我放下面了」而不是「快点小黄车」) +- ❌ **摆拍痕迹重** —— 完美构图反而失真,允许不完美 diff --git a/core/backend/skills/ecommerce-video-script/references/playbooks/structure-contrast.md b/core/backend/skills/ecommerce-video-script/references/playbooks/structure-contrast.md new file mode 100644 index 0000000..1f84af2 --- /dev/null +++ b/core/backend/skills/ecommerce-video-script/references/playbooks/structure-contrast.md @@ -0,0 +1,95 @@ +# 视频结构 · 前后对比 + +> 用「之前」和「之后」的差距直接说服。视觉冲击最强的一条结构,几乎不需要台词。 +> 核心风险:对比不可信 → 观众觉得是摆拍 → 反噬品牌。 + +--- + +## 一、结构骨架 + +``` +[ 之前 · 惨状 ] → [ 使用过程 ] → [ 之后 · 反转 ] → [ 并排复现 + CTA ] + ↑ 两端必须同机位、同光线、同角度 +``` + +| 段 | 功能 | role | 判断标准 | +| ---- | ---- | ---- | ---- | +| **1 · 之前** | 建立基准 | `钩子` | 直接展示,不铺垫。第一帧就是「之前」 | +| **2 · 使用过程** | 建立因果 | `卖点` | 让观众看到**怎么做到的**,否则反转不可信 | +| **3 · 之后** | 制造冲击 | `卖点` | 反转要在同一条件下呈现 | +| **4 · 并排 + CTA** | 落定 + 转化 | `CTA` | 左右分屏或快切复现对比,然后收 CTA | + +**时长很短(≤20 秒)时可压成三段**:之前 → 之后 → CTA,省掉过程段。 +但过程段是可信度的来源,能留就留。 + +--- + +## 二、可信度铁律(本结构的生死线) + +前后对比最容易被观众判定为「假」。守住这四条: + +1. **同机位** —— 前后两镜的角度、距离、构图必须一致 +2. **同光线** —— 不许「之前」打冷光、「之后」打暖光,这是最常见的作弊 +3. **同条件** —— 不许「之前」素颜、「之后」化妆;变量只能有一个,就是商品 +4. **过程可见** —— 中间必须有真实的使用过程,不能直接跳切 + +**在 `visual` 里明确写出「与第 X 镜同机位同光线」**,让下游生图时保持一致。 + +--- + +## 三、对比什么 + +按品类选一个维度,**只对比一个维度**: + +| 品类 | 对比维度 | 呈现方式 | +| ---- | ---- | ---- | +| 清洁 / 护理 | 状态变化 | 同一块区域的特写前后 | +| 收纳 / 家居 | 空间变化 | 同一角度的全景前后 | +| 服饰 | 版型 / 搭配 | 同一姿势的全身前后 | +| 食品 / 厨具 | 成品效果 | 同一容器同一角度 | +| 工具 / 3C | 效率变化 | 同一任务的耗时对比(可上计时器) | + +**禁止同时对比两个维度**,观众的注意力只够看一个变化。 + +--- + +## 四、时间标注 + +前后对比几乎都需要交代**时间跨度**,否则观众不知道要等多久。 + +- 在画面上或旁白里给出明确时间:「7 天」「一次」「30 秒」 +- 时间必须真实,**不许模糊化**(「短时间内」这种写法会降低可信度) +- 短时间见效的商品把时间往前放(是卖点);需要长期的就诚实说(是预期管理) + +--- + +## 五、钩子写法(第一镜) + +前后对比的钩子就是**「之后」的画面先闪一下**,或者直接从「之前」开始。 + +1. **结果前置** — 先给 1 秒「之后」,再回到「之前」(最抓人) +2. **惨状直击** — 直接展示「之前」,不加任何解释 +3. **数字冲击** —「7 天,同一个角度。」 + +--- + +## 六、台词分寸 + +**这是台词最少的一条结构。** 画面已经在说话了,台词只做三件事: + +- 交代条件(同机位、时间跨度) +- 交代过程(用了什么、怎么用) +- 收 CTA + +**别用台词描述画面已经展示的东西。** 「你看,明显干净多了」是废话,观众看得见。 + +--- + +## 七、本结构红线 + +- ❌ **前后条件不一致** —— 换光、换角度、换妆容,一律判假 +- ❌ **跳过使用过程** —— 直接从「之前」切「之后」,观众不信 +- ❌ **对比两个以上维度** —— 注意力分散,一个都记不住 +- ❌ **时间含糊** —— 必须给明确时长 +- ❌ **夸大反转幅度** —— 效果超出商品实际能力就是虚假宣传 +- ❌ **用台词代替画面** —— 这条结构的说服力在画面,不在嘴 diff --git a/core/backend/skills/ecommerce-video-script/references/playbooks/structure-pain.md b/core/backend/skills/ecommerce-video-script/references/playbooks/structure-pain.md new file mode 100644 index 0000000..a9ba147 --- /dev/null +++ b/core/backend/skills/ecommerce-video-script/references/playbooks/structure-pain.md @@ -0,0 +1,94 @@ +# 视频结构 · 痛点解决 + +> 先把观众的痛戳到,再把商品端上来。电商转化率最稳的一条结构,适用面最广。 +> 核心风险:痛点不够具体 → 观众不对号入座 → 整条视频白做。 + +--- + +## 一、结构骨架 + +``` +[ 痛点场景 ] → [ 痛点放大 ] → [ 商品介入 ] → [ 结果 + CTA ] + ↑ 必须具体到可复现的一幕 +``` + +| 段 | 功能 | role | 判断标准 | +| ---- | ---- | ---- | ---- | +| **1 · 痛点场景** | 让观众「这就是我」 | `钩子` | 一个具体的、有画面的、发生在昨天的场景 | +| **2 · 痛点放大** | 让观众意识到代价 | `痛点` | 说出这个麻烦带来的**后果**,不只是麻烦本身 | +| **3 · 商品介入** | 给出解法 | `卖点` | 卖点必须**正面对准**前面的痛点,一一对应 | +| **4 · 结果 + CTA** | 转化 | `CTA` | 结果要可见,CTA 要有动作 | + +时长短时按此顺序压缩,段可以合并但**顺序不能变**。 + +--- + +## 二、痛点怎么写才算合格 + +**唯一标准:具体到能拍出来。** + +| ❌ 不合格 | ✅ 合格 | +| ---- | ---- | +| 头发很油 | 早上洗的头,下午三点开会刘海已经一绺一绺 | +| 收纳很乱 | 每次找充电线要翻三个抽屉,最后在沙发缝里 | +| 皮肤状态不好 | 昨晚两点睡,今早粉底卡在法令纹里 | +| 做饭很麻烦 | 下班到家七点半,等米饭熟就八点十分 | + +**方法:给痛点加时间、加地点、加一个具体后果。** 抽象的痛点没有人会认领。 + +--- + +## 三、痛点放大的分寸 + +放大是为了让观众意识到「这事得解决」,不是为了制造焦虑。 + +- ✅ 放大**代价**:耽误的时间、多花的钱、错过的场合、别人的眼光 +- ❌ 不许贩卖容貌焦虑、身材焦虑、年龄焦虑 +- ❌ 不许暗示不用这个商品会有健康后果(这条越线就是违规) + +**一句话就够。** 放大段是全片最短的,超过一句就变成说教。 + +--- + +## 四、卖点必须一一对应 + +这是「痛点解决」结构最容易翻车的地方。 + +**规则:前面戳了几个痛点,后面就只讲几个卖点,且必须一一对上。** + +``` +痛点:下午三点头发就油 + ↓ 必须对上 +卖点:它的控油是靠 XX 成分吸附,我实测撑到晚上八点 +``` + +**禁止**在这里罗列商品的全部卖点。跟痛点无关的卖点一个都不要提 —— 它们只会 +稀释说服力。一条视频解决一个痛点就够了。 + +--- + +## 五、结果与 CTA + +- **结果必须可见**:同一个场景、同一个时间点,状态变了 +- CTA 给**一个**明确动作,不要给两个(「点小黄车」和「关注我」二选一) +- 紧迫感可以有,但不许用绝对化用语(「最后一天」可以,「全网最低」不行) + +--- + +## 六、钩子写法(第一镜) + +痛点解决结构的钩子优先用这三种: + +1. **痛点提问** —「你有没有过这种情况:___?」 +2. **场景直击** — 直接演那一幕,不解释(画面自己说话) +3. **共犯身份** —「我们这种___的人,都懂。」 + +--- + +## 七、本结构红线 + +- ❌ **痛点抽象** —— 最致命,写不具体就重写 +- ❌ **卖点跟痛点对不上** —— 观众会觉得答非所问 +- ❌ **一条视频戳三个痛点** —— 一个就够,多了就散 +- ❌ **贩卖焦虑** —— 放大代价可以,制造恐慌不行 +- ❌ **痛点段太长** —— 痛点占比不超过全片一半,否则观众会难受到划走 diff --git a/core/backend/skills/ecommerce-video-script/references/playbooks/structure-review.md b/core/backend/skills/ecommerce-video-script/references/playbooks/structure-review.md new file mode 100644 index 0000000..99e28c3 --- /dev/null +++ b/core/backend/skills/ecommerce-video-script/references/playbooks/structure-review.md @@ -0,0 +1,98 @@ +# 视频结构 · 测评验证 + +> 用举证的方式说服。观众要的不是「你说它好」,而是「你怎么证明它好」。 +> 客单价越高,这条结构越有效。核心风险:只有结论没有证据 → 变成软广。 + +--- + +## 一、结构骨架 + +``` +[ 提出质疑 ] → [ 设定验证方法 ] → [ 当场验证 ] → [ 给出结论 + CTA ] + ↑ 站在观众那一边,而不是商品那一边 +``` + +| 段 | 功能 | role | 判断标准 | +| ---- | ---- | ---- | ---- | +| **1 · 提出质疑** | 建立中立立场 | `钩子` | 用观众的口吻问出怀疑,不许一开始就夸 | +| **2 · 验证方法** | 建立方法可信 | `痛点` | 说清「我要怎么测」,方法要看起来公平 | +| **3 · 当场验证** | 举证 | `卖点` | 必须有**可见的过程和读数**,不能只给结论 | +| **4 · 结论 + CTA** | 转化 | `CTA` | 结论要有保留(说清适合谁不适合谁) | + +--- + +## 二、立场问题(本结构的灵魂) + +测评验证跟其他结构最大的区别:**你不站在商品这边,你站在观众这边。** + +- 开场是**质疑**,不是推荐: + - ✅「这瓶卖 299,说能扛住一整天。我不太信,今天实测。」 + - ❌「今天给大家推荐一款超好用的产品。」 +- 全片语气是**验证**,不是介绍 +- 结论必须**有保留**,全是好话反而没人信 + +**如果整条视频没有一句对商品不利的话,观众会判定这是广告。** + +--- + +## 三、证据类型(至少给两种) + +| 证据类型 | 呈现方式 | 可信度 | +| ---- | ---- | ---- | +| **实测读数** | 计时器 / 温度计 / 秤 / 尺 —— 画面上要看得见数字 | 最高 | +| **对照组** | 同时测另一个东西作参照 | 高 | +| **过程可见** | 完整拍出使用过程,不剪断 | 高 | +| **时间跨度** | 「连续用了 7 天」+ 每天记录 | 中高 | +| **第三方背书** | 检测报告 / 成分表 / 销量 | 中(不能单独用) | +| **主观感受** | 「我觉得挺好用」 | 最低(不能单独用) | + +**铁律:至少要有一种是可见的客观证据(读数或对照组)。** 全靠主观感受的 +「测评」就是伪装成测评的口播。 + +--- + +## 四、诚实分寸 + +- **必须说一个缺点。** 缺点要真实但不致命(价格偏高、颜色少、需要适应期) +- 说清**适合谁、不适合谁** —— 这既是诚实,也是精准筛选目标客户 +- 不许伪造读数、不许剪掉不利结果 +- 不许用绝对化用语和医疗功效词(这条结构最容易踩,因为在「讲效果」) + +--- + +## 五、钩子写法(第一镜) + +1. **数字质疑** —「标称 8 小时,我测测到底几小时。」 +2. **价格质疑** —「299 一瓶,凭什么?」 +3. **打脸预告** —「我本来想吐槽它的,结果……」(反转型,很抓人) +4. **对照开场** — 直接把两个东西并排放在画面里 + +--- + +## 六、镜头语言 + +- **验证过程必须是长镜或连续镜**,频繁跳剪会让人怀疑剪掉了什么 +- 读数、仪表、计时器要给**特写**,让观众自己看清 +- 人可以出画外只留手,重点在被测的东西上 +- 对照组要**同框**,不要分别拍 + +--- + +## 七、时长注意 + +测评验证是**唯一适合做长的结构**。举证需要时间,压得太短就没有证据密度。 + +- 低于 20 秒时,只能给一种证据 —— 优先给实测读数 +- 推荐 45–60 秒,能完整走完「质疑 → 方法 → 验证 → 结论」 + +--- + +## 八、本结构红线 + +- ❌ **开场就夸** —— 立场立刻崩塌,后面说什么都没用 +- ❌ **只有结论没有过程** —— 那不是测评,是广告 +- ❌ **全是优点没有缺点** —— 观众判定为软广 +- ❌ **证据全是主观感受** —— 至少要有一个客观读数 +- ❌ **伪造或含糊读数** —— 涉嫌虚假宣传 +- ❌ **做成短剧演出来** —— 演绎的测评没有可信度,这个组合是禁用的 +- ❌ **医疗功效词 / 绝对化用语** —— 本结构最高发区,输出前重点扫描 diff --git a/core/backend/skills/ecommerce-video-script/references/playbooks/structure-scene.md b/core/backend/skills/ecommerce-video-script/references/playbooks/structure-scene.md new file mode 100644 index 0000000..fc36494 --- /dev/null +++ b/core/backend/skills/ecommerce-video-script/references/playbooks/structure-scene.md @@ -0,0 +1,105 @@ +# 视频结构 · 场景种草 + +> 不讲痛点、不做对比、不举证,只把商品放进一个让人向往的场景里。 +> 观众买的是那个场景,商品是入场券。核心风险:光有氛围没有商品记忆点。 + +--- + +## 一、结构骨架 + +``` +[ 场景建立 ] → [ 商品自然入场 ] → [ 场景因它更好 ] → [ 软 CTA ] + ↑ 场景本身要有吸引力,不靠商品撑 +``` + +| 段 | 功能 | role | 判断标准 | +| ---- | ---- | ---- | ---- | +| **1 · 场景建立** | 制造向往 | `钩子` | 光、空间、氛围先立住,**商品先不出现** | +| **2 · 商品入场** | 自然带出 | `卖点` | 商品是被**使用**的,不是被展示的 | +| **3 · 场景升级** | 建立关联 | `卖点` | 让观众看到「有了它,这个场景更完整」 | +| **4 · 软 CTA** | 转化 | `CTA` | 语气要轻,不能破坏氛围 | + +--- + +## 二、场景怎么选 + +**规则:选一个观众想进入、但门槛不高的场景。** + +| ✅ 好场景 | ❌ 坏场景 | +| ---- | ---- | +| 周末早晨的厨房,光从窗户斜进来 | 豪华别墅(太远,无法代入) | +| 加班后回到家的第一件事 | 泛泛的「日常生活」(无记忆点) | +| 露营地的傍晚,炉子上在烧水 | 摄影棚白底(没有场景) | +| 出差酒店,把随身的小东西摆开 | 商品堆在桌上的平铺展示 | + +**判断标准:观众看完能说出「这是什么时候、什么地方」,才算场景立住了。** + +--- + +## 三、氛围要素(至少给三样) + +场景种草的说服力来自细节密度。每条脚本至少铺三样: + +- **光**:晨光 / 夕照 / 台灯 / 烛光 —— 光决定情绪,必须明确写进 `visual` +- **声音**:水沸 / 键盘 / 雨 / 风 —— 写进 `visual` 提示环境音 +- **触感**:蒸汽、毛毯的绒、杯壁的温度 —— 让画面有温度 +- **时间**:具体到时刻(清晨六点 / 下午三点 / 睡前) +- **陪衬物**:书、猫、外卖盒、耳机 —— 让场景像真的有人生活 + +--- + +## 四、商品怎么出场 + +**铁律:商品必须在「被使用」的状态下出现,不许摆拍展示。** + +| ✅ | ❌ | +| ---- | ---- | +| 手拿起杯子喝了一口,杯子在画面里 | 杯子放在桌上转一圈的产品镜 | +| 把它挂在帐篷上,光亮起来 | 手举着它对镜头展示 | +| 穿着它走过街角 | 平铺在床上拍细节 | + +**但商品必须被看清。** 至少要有一个镜头能让观众认出商品的外形和品牌。 +这是场景种草最容易丢的东西 —— 氛围拉满,观众记住了氛围,没记住商品。 + +--- + +## 五、钩子写法(第一镜) + +场景种草的钩子是**画面**,不是话。 + +1. **氛围直给** — 第一帧就是最好看的那一帧,不解释 +2. **时刻宣告** —「周六早上八点,我唯一不设闹钟的一天。」 +3. **动作开场** — 从一个具体动作切入(拉开窗帘、按下开关) + +**不要用提问式钩子**,那是痛点解决的写法,会破坏场景的沉浸感。 + +--- + +## 六、台词分寸 + +- 语气是**自述或旁白**,轻、慢、留白 +- **不讲参数、不讲价格、不讲功效** —— 这些会立刻把观众从场景里踢出来 +- 可以整镜无声,让环境音和画面说话 +- CTA 要软:「链接放下面了」而不是「快去抢」 + +--- + +## 七、跟其他结构的边界 + +场景种草**不承担说服功能**,它承担的是「想要」功能。 + +- 观众看完不会觉得「我需要」,而是「我想要」 +- 所以它**不适合客单价高的商品**(高客单需要理由,要用测评验证) +- 适合:食品饮料、家居小物、香氛、服饰配件、露营户外 + +--- + +## 八、本结构红线 + +- ❌ **商品没被看清** —— 最常见的失败,氛围盖过商品 +- ❌ **讲参数或价格** —— 立刻破坏沉浸感 +- ❌ **摆拍式产品展示镜** —— 商品只在被使用时出现 +- ❌ **场景太远太贵** —— 观众代入不了就没有种草效果 +- ❌ **用提问式钩子** —— 那是痛点结构的写法 +- ❌ **硬 CTA** —— 「三、二、一上链接」会毁掉整条视频的调性 +- ❌ **场景描述抽象** —— 「温馨的家」不算场景,要有光、有时间、有物件 diff --git a/core/backend/skills/video-shot-digest/SKILL.md b/core/backend/skills/video-shot-digest/SKILL.md new file mode 100644 index 0000000..aab8bf5 --- /dev/null +++ b/core/backend/skills/video-shot-digest/SKILL.md @@ -0,0 +1,104 @@ +--- +name: video-shot-digest +description: > + 上传视频提炼·分镜拆解领域技能(模型无关)。 + 服务对象不是人类,而是 AirShelf 产品后端的「上传视频提炼」入口——用户上传一条参考视频(多为已投放的电商带货成片), + 后端按时间轴均匀抽出若干帧、连同时间戳一起喂给多模态模型,加载本技能作为系统提示词。 + 能力:读一组【按时间顺序排列的视频截帧】,还原这条视频的【分镜结构】——逐镜写清画面七要素、镜头作用、 + 可读到的台词/字幕,并总结整条片子的叙事结构与节奏。 + 产出是**给人看、可人工逐镜修改的中文分镜稿**,随后由用户确认后交给脚本 agent 改写成自己商品的脚本。 + 当任务为「拆解参考视频、还原分镜、提炼视频结构、把视频变成可复用的脚本素材」时使用本技能。 +--- + +# 参考视频 · 分镜拆解师 + +你是一个**分镜拆解 agent**。输入是**一条电商带货短视频按时间顺序均匀抽出的若干帧截图**,每帧都标了它在原片中的时间点。 +你的任务是**还原这条视频的分镜结构**,产出一份**中文分镜稿**。 + +这份稿子有两个去处,缺一不可: + +1. **给用户看、给用户改** —— 用户会逐镜校对你的拆解,改错了的地方。所以必须**逐镜分段、编号清楚、说人话**。 +2. **喂给下游脚本 agent** —— 用户确认后,这份稿子会连同他自己的商品一起交给脚本 agent,改写成一条新片的脚本。所以每一镜必须写足**下游重拍时需要的信息**。 + +> **模型无关声明**:本技能不依赖任何特定模型的能力或语气。无论运行在豆包 / GPT / Gemini / Claude 上,规则一致。 + +--- + +## 铁律(优先级最高) + +### 铁律 1 · 只写你真看见的,看不见就说看不见 +截帧是**离散采样**,不是完整视频。你看到的是 8~12 个瞬间,不是全片。 + +- **禁止编造**没有画面依据的内容:编造的台词、编造的商品名、编造的品牌、编造的转场特效、编造的音乐和音效。 +- 帧与帧之间**可能发生了你没看到的事**。相邻两帧差异很大时,如实写「此处应有一次转场/换镜」,不要脑补中间过程。 +- 拿不准就用「疑似 / 大致 / 看不清」,**宁可承认看不清,也不要编一个具体的**。用户改一句话很容易,删一句编造的很烦。 +- 但**别把「(推测)」当口头禅**:截帧本来就是采样,全篇都是推测。只在**这一项真的可能有另一种答案**时才标不确定,其余照常陈述。 + +### 铁律 2 · 不要评价,只要还原 +不写「这条视频拍得很好」「节奏很棒」这类评语。你是拆解,不是影评。 +唯一允许的判断是**这一镜在整条片子里起什么作用**(钩子 / 痛点 / 卖点 / 证明 / 转化),因为下游要照着它重排结构。 + +### 铁律 3 · 声音你听不见 +你拿到的**只有画面**,没有音轨。因此: + +- **台词只能来自画面上的字幕、贴片文字、弹幕样式的花字**。看到什么抄什么,**逐字照抄,不要润色**。 +- 这一镜画面上没有任何文字时,`台词/字幕` 一栏就写一个「无」,**绝不替它编一句口播词**。 + 整条片子都没有字幕时,在 `【拆解存疑】` 里**统一说一次**「全片无字幕,口播词缺失需人工补」即可,**不要每镜重复一遍**——用户要逐镜改稿,重复的免责声明只会碍事。 +- 不要写任何关于 BGM、音效、语气、音量的描述。 + +--- + +## 画面七要素(每一镜必须写全) + +这七项是**下游重拍时的最小信息量**,一项都不能省。看不清的那一项写「看不清」,但**不能整项不写**。 + +| # | 要素 | 写什么 | 例 | +|---|------|--------|-----| +| 1 | **主体** | 画面里的人 / 物是谁,几个人,什么身份 | 一位 25 岁左右女生,独自 | +| 2 | **动作** | 主体在做什么,动作的起止 | 从沙发上坐起、伸手去够茶几上的瓶子 | +| 3 | **场景** | 在哪,环境里的关键陈设 | 出租屋客厅,米色沙发 + 木茶几 + 落地窗 | +| 4 | **景别** | 特写 / 近景 / 中景 / 全景 / 远景 | 中景(腰以上) | +| 5 | **运镜** | 固定 / 推 / 拉 / 摇 / 移 / 跟拍 / 手持晃动 | 固定机位,轻微手持晃 | +| 6 | **光线氛围** | 光的方向与色温、整体色调与情绪 | 窗户侧逆光,暖黄,慵懒 | +| 7 | **商品露出** | 商品怎么出现的:手持 / 特写 / 使用中 / 背景陈列 / 未出现 | 手持展示,正面标签朝镜头 | + +> 相邻帧属于同一镜(主体、场景、景别都没变)就**合并成一镜**,不要一帧算一镜。 +> 反过来,一帧内如果明显发生了切换(如画面被分割、出现了明显的转场帧),可以拆成两镜并注明是推测。 + +--- + +## 输出格式(严格照抄这个骨架,纯文本,不要 JSON、不要代码块) + +``` +【整体结构】 +形式:口播 / 短剧 / Vlog(三选一,看不出就写「看不出」) +结构:痛点解决 / 前后对比 / 测评验证 / 场景种草(四选一,看不出就写「看不出」) +时长:约 N 秒 · 共 M 镜 +主线:一句话说清这条片子从头到尾讲了什么 + +【第 1 镜】0-3 秒 · 钩子 +主体:… +动作:… +场景:… +景别:… +运镜:… +光线氛围:… +商品露出:… +台词/字幕:… +这一镜的作用:… + +【第 2 镜】3-8 秒 · 痛点 +(同上七要素 + 台词/字幕 + 作用) + +…(有几镜写几镜) + +【拆解存疑】 +- 逐条列出你不确定的地方,让用户重点校对(如:第 3 镜和第 4 镜之间可能还有一镜;商品品类看不清;全片无字幕,口播词缺失) +``` + +### 格式硬要求 +- 镜号连续,从 1 开始。 +- 时间区间用抽帧时间戳推算,写成「0-3 秒」这种闭区间,**不要**写小数。 +- 每镜的「作用」只能从**钩子 / 痛点 / 卖点 / 证明 / 转化 / 过渡**里选一个词,后面可以跟一句话解释。 +- **`【拆解存疑】`一节必须有**,哪怕只有一条。这一节是给用户的校对指引,是整份稿子最有用的部分之一——你拆错了不要紧,标出来让人改就行。 +- 全文中文,不出现 markdown 标题符号(`#`)和代码块围栏。 diff --git a/core/frontend/src/App.tsx b/core/frontend/src/App.tsx index 23715ce..bc18c72 100644 --- a/core/frontend/src/App.tsx +++ b/core/frontend/src/App.tsx @@ -21,6 +21,7 @@ import type { import { publicModelDisplayName } from "./model-display"; import { generationErrorText } from "./generation-error"; import { CornerMarks, Decorations, Sidebar, ToastLike } from "./components/app-shell"; +import { SystemLoading } from "./components/loading"; import { AccountPage, AssetFactoryPage, @@ -769,20 +770,12 @@ export function App() { if (booting || !user || !team) { return ( -
-
-
-
-
-

正在进入工作台…

-
- // 正在拉取团队数据 -
-
-
-
-
-
+ ); } @@ -985,31 +978,22 @@ export function App() { // 详情还在拉取(刚切项目 / 首进管线):显示全屏加载占位,等完整详情到位再渲染,而不是拿轻量数据去崩。 // 拉取多次仍失败则给重试 + 返回,避免「永久卡 loading 进不来」。 if (page === "pipeline" && activeProjectId && !pipelineProject) { + const projectRef = activeProjectId.slice(0, 8).toUpperCase(); return ( -
-
-
-
-
-

{projectDetailError ? "项目加载失败" : "加载中…"}

-
- {projectDetailError ? "// 网络异常或服务繁忙" : "// 正在拉取项目数据"} -
-
- {projectDetailError && ( -
- - -
- )} -
-
-
-
+ + + + + ) : undefined} + /> ); } if (page === "pipeline" && pipelineProject) { diff --git a/core/frontend/src/admin-page.css b/core/frontend/src/admin-page.css index ea242ad..8c3b863 100644 --- a/core/frontend/src/admin-page.css +++ b/core/frontend/src/admin-page.css @@ -198,6 +198,20 @@ /* ── 资产审核队列 ── */ .admin-table .col-check { width: 36px; text-align: center; } +.admin-thumb-btn { + display: block; + padding: 0; + margin: 0; + border: 0; + background: transparent; + cursor: zoom-in; + border-radius: var(--r-md); + line-height: 0; +} +.admin-thumb-btn:focus-visible { + outline: none; + box-shadow: 0 0 0 2px var(--background-base), 0 0 0 4px var(--heat-40); +} .admin-thumb { width: 40px; height: 40px; @@ -206,6 +220,7 @@ background: var(--background-lighter); border: 1px solid var(--border-faint); display: block; + pointer-events: none; } .admin-thumb-empty { display: inline-block; } .admin-review-err { @@ -220,14 +235,14 @@ font-size: 11px; cursor: help; } -/* 收窄到 .admin-app 作用域:`.bulk-bar` 是全局裸类(商品库/项目/资产库各有黑底版), - 不限定会随 main.tsx 末位加载盖掉那些黑底浮条 → 全站批量栏被染白。 */ -.admin-app .bulk-bar { +/* 独立类名,避开商品库/项目页全局黑底 `.bulk-bar button`(白字透明边), + 否则管理台白底浮条上按钮文字不可见,只剩一条扁边框。 */ +.admin-bulk-bar { position: fixed; left: 50%; bottom: 28px; transform: translateX(-50%); - display: flex; + display: inline-flex; align-items: center; gap: 12px; padding: 10px 16px; @@ -237,7 +252,12 @@ box-shadow: var(--shadow-floating); z-index: 50; } -.admin-app .bulk-count { font-size: 13px; color: var(--accent-black); font-weight: 500; } +.admin-bulk-count { + font-size: 13px; + color: var(--accent-black); + font-weight: 500; + white-space: nowrap; +} /* ── 任务监控 ── */ .admin-anomaly-chip { height: 28px; padding: 0 14px; font-size: 12.5px; cursor: pointer; } diff --git a/core/frontend/src/api.ts b/core/frontend/src/api.ts index 764ec30..607be18 100644 --- a/core/frontend/src/api.ts +++ b/core/frontend/src/api.ts @@ -44,6 +44,7 @@ import type { UserPreference, VoiceoverInfo } from "./types"; +import type { PresentationFormat, VideoStructure } from "./script-setup"; const API_BASE = import.meta.env.VITE_API_BASE_URL || ""; const TOKEN_KEY = "airshelf_token"; @@ -326,6 +327,21 @@ export const api = { purgeProject(id: string) { return request(`/api/projects/${id}/purge/`, { method: "DELETE" }); }, + // 上传脚本(docx)取正文:docx 是 zip,交后端拆,前端不引解析库。txt 前端自己 file.text() 即可。 + extractScriptFile(projectId: string, formData: FormData) { + return request<{ name: string; chars: number; text: string }>( + `/api/projects/${projectId}/extract-script-file/`, + { method: "POST", body: formData } + ); + }, + // 1.11 上传视频提炼:后端抽帧 → 多模态读帧 → 中文分镜稿。与上传脚本同形状(只回文本不落库), + // 差别是真调模型,慢(整条 30~60 秒)且计一次费,故前端要给等待反馈。 + extractScriptVideo(projectId: string, formData: FormData) { + return request<{ name: string; chars: number; text: string; frames: number; duration: number }>( + `/api/projects/${projectId}/extract-script-video/`, + { method: "POST", body: formData } + ); + }, // 对话式脚本 agent · 流式(SSE)。逐帧回调 onEvent:tool(工具卡)/delta(思考前言)/draft/saved/done/error。 // 用 fetch + ReadableStream 消费 text/event-stream(EventSource 只支持 GET,这里要 POST 带 body)。 async agentScriptStream( @@ -338,7 +354,12 @@ export const api = { base_version_id?: string; aspect_ratio?: string; total_duration?: number; + // 二期:表现形式 × 视频结构 —— 后端按这两个挑套路提示词,不传则回落 口播 × 痛点解决 + presentation_format?: PresentationFormat; + video_structure?: VideoStructure; target_index?: number; + // 哪个入口来的:辅助生成 / 上传脚本 / 上传视频提炼。三者都是 mode=auto,靠它区分并记进脚本「来源」 + source?: "ai" | "manual" | "video"; }, onEvent: (evt: { type: string; [k: string]: unknown }) => void, signal?: AbortSignal @@ -542,9 +563,13 @@ export const api = { videoPacks() { return request("/api/assets/video-packs/"); }, - // 手动兜底:把单个送审类素材(角色/三视图/分镜)送火山审核(用户点灰盾「待审核」时) - submitAssetReview(id: string) { - return request<{ review_status: string; review_error: string }>(`/api/assets/${id}/submit-review/`, { method: "POST" }); + // 手动兜底:把单个送审类素材(角色/三视图/分镜)送火山审核(用户点灰盾「待审核」时)。 + // forReference:素材要拿去当生成参考(自由创作 @引用三库),此时不看类目白名单一律送审。 + submitAssetReview(id: string, forReference = false) { + return request<{ review_status: string; review_error: string }>(`/api/assets/${id}/submit-review/`, { + method: "POST", + body: JSON.stringify({ for_reference: forReference }) + }); }, // 置 in_library(功能性模特/角色资料不走该入口;图片成图仍按原规则入库) setAssetLibrary(id: string, inLibrary: boolean) { diff --git a/core/frontend/src/components/free-create/input-bar.tsx b/core/frontend/src/components/free-create/input-bar.tsx index 5097a2d..7dbbe83 100644 --- a/core/frontend/src/components/free-create/input-bar.tsx +++ b/core/frontend/src/components/free-create/input-bar.tsx @@ -59,7 +59,7 @@ function KeyframeSlot({ role, item, onPickLibrary, onRemove }: { ); } -export function FreeInputBar({ mode, model, ratio, resolution, duration, seed, refs, videoConfigs, billingRates, submitting, promptRef, onFiles, onRemoveRef, onModeChange, onModelChange, onRatioChange, onResolutionChange, onDurationChange, onSeedChange, onOpenLibrary, onClear, onSend }: { +export function FreeInputBar({ mode, model, ratio, resolution, duration, seed, refs, videoConfigs, billingRates, submitting, promptRef, onFiles, onRemoveRef, onModeChange, onModelChange, onRatioChange, onResolutionChange, onDurationChange, onSeedChange, onOpenLibrary, onOpenPlatformLibrary, onClear, onSend }: { mode: FreeMode; model: string; ratio: string; @@ -81,6 +81,7 @@ export function FreeInputBar({ mode, model, ratio, resolution, duration, seed, r onDurationChange: (duration: number) => void; onSeedChange: (seed: number) => void; onOpenLibrary: (role?: "first_frame" | "last_frame") => void; + onOpenPlatformLibrary: (role?: "first_frame" | "last_frame") => void; onClear: () => void; onSend: () => void; }) { @@ -134,6 +135,9 @@ export function FreeInputBar({ mode, model, ratio, resolution, duration, seed, r + {refs.map((item) => ( onRemoveRef(item.key)} /> ))} diff --git a/core/frontend/src/components/free-create/platform-library-modal.tsx b/core/frontend/src/components/free-create/platform-library-modal.tsx new file mode 100644 index 0000000..e4b15b9 --- /dev/null +++ b/core/frontend/src/components/free-create/platform-library-modal.tsx @@ -0,0 +1,381 @@ +// 自由创作·三库引用弹窗(模块4 · 4.1/4.3):资产库 / 模特库 / 商品库 三个 tab 挑素材。 +// 三个库挑出来的最终都是一行平台 Asset,所以注入输入条时统一 source=asset, +// 后端 build_content_items 只有一个分支要处理,审核闸也只有一处。 +import { useCallback, useEffect, useMemo, useState } from "react"; +import { createPortal } from "react-dom"; +import { ChevronLeft, Images, Package, Search, ShieldCheck, User, X } from "lucide-react"; +import { api } from "../../api"; +import type { Asset, FreeVideoRef, ModelEntity, Product } from "../../types"; +import { useBodyScrollLock } from "../overlays"; + +type LibTab = "assets" | "models" | "products"; + +const TAB_LABEL: Record = { assets: "资产库", models: "模特库", products: "商品库" }; +const PAGE_SIZE = 24; + +/** 引用前的审核判定。与后端 apps/assets/review.py 的 reference_review_state 同一套规则: + * 平台生成的免审,用户上传的必须审过。这里只为提前给个说法,真正拦人的是后端。 */ +type RefState = "allowed" | "processing" | "failed" | "unsubmitted"; + +function refState(asset: Pick): RefState { + if (asset.source === "ai_generated" || asset.source === "system") return "allowed"; + if (asset.review_status === "active") return "allowed"; + if (asset.review_status === "processing") return "processing"; + if (asset.review_status === "failed") return "failed"; + return "unsubmitted"; +} + +const STATE_PILL: Record, { cls: string; label: string }> = { + processing: { cls: "pill-info", label: "审核中" }, + failed: { cls: "pill-err", label: "未通过" }, + unsubmitted: { cls: "pill-warn", label: "待送审" } +}; + +function assetThumb(asset: Asset): string { + const primary = asset.files?.find((f) => f.is_primary) || asset.files?.[0]; + return primary?.preview_url || ""; +} + +export function PlatformLibraryModal({ open, imageOnly, onClose, onPick, notify }: { + open: boolean; + /** 首尾帧模式只能用图片 */ + imageOnly: boolean; + onClose: () => void; + onPick: (ref: FreeVideoRef) => void; + notify: (type: "success" | "error" | "info", text: string) => void; +}) { + const [tab, setTab] = useState("assets"); + const [queryInput, setQueryInput] = useState(""); + const [query, setQuery] = useState(""); + const [loading, setLoading] = useState(false); + const [assets, setAssets] = useState([]); + const [page, setPage] = useState(1); + const [hasMore, setHasMore] = useState(false); + const [models, setModels] = useState([]); + const [products, setProducts] = useState([]); + // 商品库是两层的:先选商品,再从它的图里挑一张 + const [openProduct, setOpenProduct] = useState(null); + const [productAssets, setProductAssets] = useState([]); + const [submittingReview, setSubmittingReview] = useState(null); + useBodyScrollLock(open); + + const resetView = useCallback(() => { + setAssets([]); + setModels([]); + setProducts([]); + setOpenProduct(null); + setProductAssets([]); + setPage(1); + setHasMore(false); + }, []); + + useEffect(() => { + if (!open) return; + setTab("assets"); + setQueryInput(""); + setQuery(""); + resetView(); + }, [open, resetView]); + + // 搜索框防抖:不防抖会一个字一次请求,列表还会边打边跳 + useEffect(() => { + const timer = window.setTimeout(() => setQuery(queryInput.trim()), 300); + return () => window.clearTimeout(timer); + }, [queryInput]); + + const loadAssets = useCallback(async (nextPage: number) => { + setLoading(true); + try { + const data = await api.assetsPage({ + q: query || undefined, + asset_type: imageOnly ? "image" : undefined, + page: nextPage, + pageSize: PAGE_SIZE + }); + setAssets((prev) => (nextPage === 1 ? data.results : [...prev, ...data.results])); + setPage(nextPage); + setHasMore(Boolean(data.next)); + } catch (error) { + notify("error", error instanceof Error ? error.message : "资产加载失败"); + } finally { + setLoading(false); + } + }, [query, imageOnly, notify]); + + const loadModels = useCallback(async () => { + setLoading(true); + try { + const data = await api.listModels({ q: query || undefined }); + setModels(data.results); + } catch (error) { + notify("error", error instanceof Error ? error.message : "模特库加载失败"); + } finally { + setLoading(false); + } + }, [query, notify]); + + const loadProducts = useCallback(async () => { + setLoading(true); + try { + const data = await api.products(); + const keyword = query.trim().toLowerCase(); + setProducts(keyword + ? data.results.filter((p) => `${p.title} ${p.brand} ${p.category}`.toLowerCase().includes(keyword)) + : data.results); + } catch (error) { + notify("error", error instanceof Error ? error.message : "商品库加载失败"); + } finally { + setLoading(false); + } + }, [query, notify]); + + useEffect(() => { + if (!open) return; + resetView(); + if (tab === "assets") void loadAssets(1); + else if (tab === "models") void loadModels(); + else void loadProducts(); + // query 变化也要重拉,所以它在依赖里 + }, [open, tab, loadAssets, loadModels, loadProducts, resetView]); + + // 商品下钻:把它的封面图 + 素材图取成一组资产 + const openProductDetail = useCallback(async (product: Product) => { + setOpenProduct(product); + setLoading(true); + try { + const data = await api.assetsPage({ product: product.id, asset_type: imageOnly ? "image" : undefined, pageSize: 100 }); + setProductAssets(data.results); + } catch (error) { + notify("error", error instanceof Error ? error.message : "商品素材加载失败"); + } finally { + setLoading(false); + } + }, [imageOnly, notify]); + + const pickAsset = useCallback((asset: Asset, labelHint?: string) => { + const state = refState(asset); + if (state === "processing") { notify("info", "这个素材还在审核中,通过后才能引用"); return; } + if (state === "failed") { notify("error", asset.review_error || "这个素材未通过审核,不能引用"); return; } + if (state === "unsubmitted") { notify("info", "上传素材要先审核通过才能引用,点卡片上的「送审」提交"); return; } + const kind = asset.asset_type === "video" ? "video" : asset.asset_type === "audio" ? "audio" : "image"; + if (imageOnly && kind !== "image") { notify("error", "首尾帧仅支持图片素材"); return; } + const url = assetThumb(asset); + if (!url) { notify("error", "这个素材没有可用文件"); return; } + onPick({ + url, + type: kind, + label: (labelHint || asset.display_name || asset.name || "素材").slice(0, 24), + thumb_url: kind === "image" ? url : undefined, + asset_id: asset.id, + source: "asset" + }); + onClose(); + }, [imageOnly, onPick, onClose, notify]); + + const sendToReview = useCallback(async (asset: Asset) => { + setSubmittingReview(asset.id); + try { + const data = await api.submitAssetReview(asset.id, true); + const patch = (list: Asset[]) => list.map((a) => (a.id === asset.id ? { ...a, review_status: data.review_status } : a)); + setAssets(patch); + setProductAssets(patch); + notify("success", "已提交审核,通过后即可引用"); + } catch (error) { + notify("error", error instanceof Error ? error.message : "送审失败"); + } finally { + setSubmittingReview(null); + } + }, [notify]); + + // 模特一张卡最多两个可引用资产:形象图 + 三视图 + const modelAssets = useCallback((model: ModelEntity) => { + const out: Array<{ id: string; url: string; tag: string }> = []; + if (model.portrait_asset && model.portrait) out.push({ id: model.portrait_asset, url: model.portrait, tag: "形象图" }); + if (model.triview_asset && model.triview) out.push({ id: model.triview_asset, url: model.triview, tag: "三视图" }); + return out; + }, []); + + // 模特库的资产是平台生成/上传的 Model 附属资产,拿不到完整 Asset 行 → 交给后端判审核, + // 这里直接按 id 引用,后端 _guard_asset_reference 会给出准确的放行或拒绝理由。 + const pickModelAsset = useCallback((model: ModelEntity, item: { id: string; url: string; tag: string }) => { + onPick({ + url: item.url, + type: "image", + label: `${model.name}${item.tag === "三视图" ? "三视图" : ""}`.slice(0, 24), + thumb_url: item.url, + asset_id: item.id, + source: "asset" + }); + onClose(); + }, [onPick, onClose]); + + const gridAssets = openProduct ? productAssets : assets; + const emptyText = useMemo(() => { + if (tab === "models") return "还没有模特"; + if (tab === "products") return openProduct ? "这个商品还没有素材" : "还没有商品"; + return "资产库里还没有可引用的素材"; + }, [tab, openProduct]); + + if (!open) return null; + + const renderAssetCard = (asset: Asset, labelHint?: string) => { + const state = refState(asset); + const pill = state === "allowed" ? null : STATE_PILL[state]; + const thumb = assetThumb(asset); + return ( +
pickAsset(asset, labelHint)} + onKeyDown={(event) => { if (event.key === "Enter") pickAsset(asset, labelHint); }} + > +
+ {thumb && asset.asset_type === "image" + ? {asset.name} + : {asset.asset_type === "audio" ? "♪" : asset.asset_type.toUpperCase()}} +
+
{asset.display_name || asset.name}
+ {pill + ? {pill.label} + : // {asset.source === "upload" ? "已过审" : "平台生成"}} + {state === "unsubmitted" && ( + + )} +
+ ); + }; + + return createPortal( +
+
event.stopPropagation()}> + ++ +
+
+
+ {openProduct ? ( + + ) : "引用平台素材"} + // 资产库 · 模特库 · 商品库 +
+ +
+
+ {!openProduct && ( +
+ {(Object.keys(TAB_LABEL) as LibTab[]).map((key) => ( +
setTab(key)} + onKeyDown={(event) => { if (event.key === "Enter") setTab(key); }}> + {TAB_LABEL[key]} +
+ ))} +
+ )} +
+ {/* 商品下钻后列的是这一个商品的素材,搜索框在这层没有意义,收起来免得成死控件 */} + {!openProduct && ( + + )} + + {imageOnly ? "// 首尾帧只列图片素材" : "// 平台生成的直接可用,上传的要审核通过"} + +
+ + {tab === "models" && !openProduct ? ( + models.length === 0 && !loading ? ( +
+ +

{emptyText}

+

// 去模特库添加模特后可在这里引用形象图与三视图

+
+ ) : ( +
+ {models.map((model) => { + const items = modelAssets(model); + return ( +
+
+ {model.portrait ? {model.name} : } +
+
{model.name}
+
+ {items.length === 0 + ? // 还没有可引用的图 + : items.map((item) => ( + + ))} +
+
+ ); + })} +
+ ) + ) : tab === "products" && !openProduct ? ( + products.length === 0 && !loading ? ( +
+ +

{emptyText}

+

// 去商品库建商品后可在这里引用它的素材

+
+ ) : ( +
+ {products.map((product) => ( +
void openProductDetail(product)} + onKeyDown={(event) => { if (event.key === "Enter") void openProductDetail(product); }}> +
+ {product.cover_preview_url ? {product.title} : } +
+
{product.title}
+
// {product.category || "未分类"}
+
+ ))} +
+ ) + ) : gridAssets.length === 0 && !loading ? ( +
+ +

{emptyText}

+

// 平台生成的素材可直接引用,上传的要先审核通过

+
+ ) : ( + <> +
+ {gridAssets.map((asset) => renderAssetCard(asset, openProduct ? `${openProduct.title}` : undefined))} +
+ {!openProduct && tab === "assets" && hasMore && ( +
+ +
+ )} + + )} +
+
+
, + document.body + ); +} diff --git a/core/frontend/src/components/loading.css b/core/frontend/src/components/loading.css index c4420bf..28f0c73 100644 --- a/core/frontend/src/components/loading.css +++ b/core/frontend/src/components/loading.css @@ -25,3 +25,141 @@ color: var(--black-alpha-56, #8a8a8a); padding: 4px 0 12px; } + +/* 全局加载面板:用于整页、主要区块与弹窗内容首次加载。 */ +.system-loading { + width: 100%; + display: grid; + place-items: center; + padding: 32px 24px; +} +.system-loading--fullscreen { min-height: 100vh; padding: 48px 24px; } +.system-loading--page { min-height: min(480px, 62vh); } +.system-loading--inline { min-height: 200px; padding: 24px 16px; } +.system-loading-card { + position: relative; + width: min(440px, 100%); + padding: 28px; + background: var(--surface); + border-radius: var(--r-md); + box-shadow: inset 0 0 0 1px var(--border-faint); +} +.system-loading--inline .system-loading-card { width: min(400px, 100%); padding: 24px; } +.system-loading-corner { + position: absolute; + z-index: 1; + width: 14px; + height: 14px; + display: grid; + place-items: center; + color: var(--black-alpha-24); + background: var(--background-base); + font-family: var(--font-mono); + font-size: 11px; + line-height: 1; +} +.system-loading-corner.corner-tl { top: -7px; left: -7px; } +.system-loading-corner.corner-tr { top: -7px; right: -7px; } +.system-loading-corner.corner-bl { bottom: -7px; left: -7px; } +.system-loading-corner.corner-br { right: -7px; bottom: -7px; } +.system-loading-meta, +.system-loading-foot { + display: flex; + align-items: center; + justify-content: space-between; + gap: 16px; + color: var(--black-alpha-48); + font-size: 10.5px; + letter-spacing: .05em; + white-space: nowrap; +} +.system-loading-meta span:last-child { color: var(--heat); } +.system-loading-main { + display: flex; + align-items: center; + gap: 18px; + padding: 36px 0 30px; +} +.system-loading--inline .system-loading-main { padding: 28px 0 24px; } +.system-loading-icon { + position: relative; + width: 44px; + height: 44px; + flex: 0 0 44px; + display: grid; + place-items: center; + color: var(--heat); + background: var(--heat-12); + border-radius: var(--r-md); +} +.system-loading-orbit { + position: absolute; + inset: -5px; + border: 1px solid var(--heat-20); + border-top-color: var(--heat); + border-radius: var(--r-md); + animation: system-loading-spin 1.1s linear infinite; +} +.system-loading-copy { min-width: 0; } +.system-loading-copy h2 { + margin: 0; + color: var(--accent-black); + font-size: 18px; + font-weight: 500; + letter-spacing: -.01em; + line-height: 1.4; +} +.system-loading-copy p { + margin: 6px 0 0; + color: var(--black-alpha-56); + font-size: 12.5px; + line-height: 1.7; +} +.system-loading-progress { + display: grid; + grid-template-columns: repeat(5, 1fr); + gap: 4px; + height: 3px; + margin-bottom: 22px; +} +.system-loading-progress span { + border-radius: 2px; + background: var(--black-alpha-8); + animation: system-loading-step 1.5s ease-in-out infinite; +} +.system-loading-progress span:nth-child(2) { animation-delay: .12s; } +.system-loading-progress span:nth-child(3) { animation-delay: .24s; } +.system-loading-progress span:nth-child(4) { animation-delay: .36s; } +.system-loading-progress span:nth-child(5) { animation-delay: .48s; } +.system-loading-foot { + padding-top: 16px; + border-top: 1px solid var(--border-faint); + font-size: 10px; +} +.system-loading-actions { + display: flex; + align-items: center; + gap: 8px; + margin-bottom: 22px; +} +.system-loading.is-error .system-loading-meta span:last-child, +.system-loading.is-error .system-loading-icon { color: var(--accent-crimson); } +.system-loading.is-error .system-loading-icon { background: var(--crimson-bg); } + +@keyframes system-loading-spin { + to { transform: rotate(360deg); } +} +@keyframes system-loading-step { + 0%, 70%, 100% { background: var(--black-alpha-8); } + 28% { background: var(--heat); } +} +@media (prefers-reduced-motion: reduce) { + .system-loading-orbit, + .system-loading-progress span { animation: none; } + .system-loading-progress span:first-child { background: var(--heat); } +} +@media (max-width: 520px) { + .system-loading-card { padding: 24px; } + .system-loading-meta span:first-child, + .system-loading-foot span:last-child { display: none; } +} diff --git a/core/frontend/src/components/loading.tsx b/core/frontend/src/components/loading.tsx index 830e900..5bcf84e 100644 --- a/core/frontend/src/components/loading.tsx +++ b/core/frontend/src/components/loading.tsx @@ -1,5 +1,65 @@ +import type { ReactNode } from "react"; +import { IconKitSvg } from "./IconKitSvg"; import "./loading.css"; +export function SystemLoading({ + variant = "page", + state = "loading", + title = "正在加载", + description = "正在同步最新数据,请稍候。", + icon = "activity", + reference, + actions, +}: { + variant?: "inline" | "page" | "fullscreen"; + state?: "loading" | "error"; + title?: string; + description?: string; + icon?: string; + reference?: string; + actions?: ReactNode; +}) { + return ( +
+
+ + + + + +
+ [ AIRSHELF / SYSTEM ] + {state === "error" ? "[ CONNECTION ERROR ]" : "[ SYNCING ]"} +
+ +
+ +
+

{title}

+

{description}

+
+
+ + {actions ? ( +
{actions}
+ ) : state === "loading" ? ( + + ) : null} + +
+ {reference ? `// REF · ${reference}` : "// DATA SYNC"} + {state === "error" ? "RETRY AVAILABLE" : "DATA HYDRATION"} +
+
+
+ ); +} + // 列表数据在途时的骨架屏(grid 卡片 / 行),替代「为空」状态,让用户看到「加载中」而非「没数据」。 export function SkeletonGrid({ count = 8 }: { count?: number }) { return ( diff --git a/core/frontend/src/free-create-page.css b/core/frontend/src/free-create-page.css index c14fbe0..43f9763 100644 --- a/core/frontend/src/free-create-page.css +++ b/core/frontend/src/free-create-page.css @@ -605,6 +605,11 @@ color: var(--accent-crimson); border-color: var(--crimson-bd); } +.fc-lib-status.pill-warn { + background: var(--honey-bg); + color: var(--accent-honey); + border-color: var(--honey-bd); +} .fc-lib-status .dot { width: 7px; height: 7px; @@ -678,6 +683,16 @@ background: var(--crimson-bg); } +/* —— 三库引用弹窗(资产库 / 模特库 / 商品库)—— */ +.fc-lib-tabs { margin-bottom: 14px; } +.fc-lib-search { position: relative; display: flex; align-items: center; flex: 1; max-width: 280px; } +.fc-lib-search svg { position: absolute; left: 10px; z-index: 2; color: var(--black-alpha-48); } +.fc-lib-search .input { height: 30px; width: 100%; padding-left: 30px; } +.fc-lib-review-btn { height: 24px; padding: 0 8px; font-size: 11.5px; align-self: flex-start; } +.fc-lib-model-picks { display: flex; flex-wrap: wrap; gap: 6px; } +.fc-lib-model-picks .btn { height: 24px; padding: 0 8px; font-size: 11.5px; } +.fc-lib-more { display: flex; justify-content: center; padding-top: 14px; } + /* 移动端:输入条参数换行、任务流单列 */ @media (max-width: 720px) { .fc-feed { grid-template-columns: 1fr; } diff --git a/core/frontend/src/pipeline-page.css b/core/frontend/src/pipeline-page.css index 3f85c75..550bd47 100644 --- a/core/frontend/src/pipeline-page.css +++ b/core/frontend/src/pipeline-page.css @@ -139,6 +139,8 @@ .chat-input-foot .spacer { flex: 1; } .chat-icon-btn { width: 28px; height: 28px; display: grid; place-items: center; background: transparent; border: 1px solid var(--border-faint); border-radius: 50%; color: var(--ink-3); cursor: pointer; transition: border-color var(--t-base), color var(--t-base), background var(--t-base); } .chat-icon-btn:hover { border-color: var(--accent-black); color: var(--accent-black); } + .chat-icon-btn:disabled { opacity: .45; cursor: not-allowed; } + .chat-icon-btn:disabled:hover { border-color: var(--border-faint); color: var(--ink-3); } .chat-send-btn { width: 32px; height: 32px; display: grid; place-items: center; background: var(--accent-black); border: 1px solid var(--accent-black); border-radius: 50%; color: var(--accent-white); cursor: pointer; transition: background var(--t-base), border-color var(--t-base), transform var(--t-base); } .chat-send-btn:hover { background: var(--heat); border-color: var(--heat); } .chat-send-btn:active { transform: scale(.95); } @@ -313,6 +315,10 @@ .prod-preview-history .h-thumb.adopted .badge { display: block; } .prod-preview-img.is-zoomable { cursor: zoom-in; transition: border-color var(--t-base); } .prod-preview-img.is-zoomable:hover { border-color: var(--heat-40); } + /* 三视图空态 = 上传框:虚线示意可投放,hover 转橙提示可点 */ + .prod-tri-drop { cursor: pointer; border-style: dashed; transition: border-color var(--t-base); } + .prod-tri-drop:hover { border-color: var(--heat-40); } + .prod-tri-drop:hover .ph-frame { color: var(--heat); border-color: var(--heat-20); } .prod-preview-adopt:disabled, .prod-preview-adopt:disabled:hover { color: var(--heat); border-color: var(--heat-40); background: var(--heat-12); cursor: not-allowed; opacity: 1; } .asset-card-2 { background: var(--surface); border: 1px solid var(--border-faint); border-radius: var(--r-md); cursor: pointer; transition: border-color var(--t-base), box-shadow var(--t-base); overflow: hidden; display: flex; flex-direction: column; } @@ -612,14 +618,17 @@ .clamp-toggle { margin-top: 6px; padding: 0; background: none; border: 0; color: var(--heat); font-family: inherit; font-size: 12px; cursor: pointer; } .clamp-toggle:hover { text-decoration: underline; } - /* ── 行28/行30 · 生成前「来源 & 风格 & 人物」设定卡 ── */ + /* ── 1.5 · 生成前「表现形式 × 视频结构 × 人物 × 时长」设定卡 ── */ + /* 标签列 64px:装得下最长的「表现形式」四个字(12px mono ≈ 52px)且四行对齐 */ .bubble.setup-card { width: 100%; max-width: 100%; display: flex; flex-direction: column; gap: 4px; } .setup-card .setup-lead { font-size: 13px; line-height: 1.5; color: var(--accent-black); margin-bottom: 6px; } - .setup-card .setup-field { display: grid; grid-template-columns: 40px 1fr; align-items: center; gap: 10px; } + .setup-card .setup-field { display: grid; grid-template-columns: 64px 1fr; align-items: center; gap: 10px; } .setup-card .setup-field .sf-k { font-family: var(--font-mono); font-size: 12px; color: var(--black-alpha-48); letter-spacing: .04em; } .setup-card .setup-select { height: 32px; width: 100%; padding: 0 10px; font-size: 13px; font-family: inherit; color: var(--accent-black); background: var(--surface); border: 1px solid var(--border-faint); border-radius: var(--r-sm); outline: none; cursor: pointer; } .setup-card .setup-select:focus { border-color: var(--heat); box-shadow: 0 0 0 3px var(--heat-12); } - .setup-card .setup-rec { grid-column: 2; margin-left: 50px; font-size: 12px; color: var(--black-alpha-40); margin: 0 0 8px 50px; } + /* 说明行左缩进 = 标签列 64 + gap 10,跟下拉框左边缘对齐 */ + .setup-card .setup-rec { font-size: 12px; line-height: 1.5; color: var(--black-alpha-40); margin: 0 0 8px 74px; } + .setup-card .setup-rec.warn { color: var(--accent-honey); } .setup-card .setup-foot { display: flex; align-items: center; justify-content: flex-end; gap: 8px; margin-top: 4px; } /* ── 行34 · 添加分镜:本地草稿可编辑卡片 ── */ diff --git a/core/frontend/src/routes/admin/admin-billing.tsx b/core/frontend/src/routes/admin/admin-billing.tsx index 5b2b799..118b026 100644 --- a/core/frontend/src/routes/admin/admin-billing.tsx +++ b/core/frontend/src/routes/admin/admin-billing.tsx @@ -3,6 +3,7 @@ import { Gauge, Wallet, X } from "lucide-react"; import { adminApi } from "../../api"; import { Pager } from "../../components/pager"; import { IconKitSvg } from "../../components/IconKitSvg"; +import { SystemLoading } from "../../components/loading"; import type { AdminLedger, AdminQuotaPolicy, AdminTeam } from "../../types"; import { pts } from "../stage-config"; @@ -175,7 +176,7 @@ export function AdminLedgersPage({ notify }: { notify: Notify }) { {loading ? ( -

加载中…

// fetching ledgers

+ ) : ledgers.length === 0 ? (

暂无流水

// no ledgers

) : ( @@ -331,7 +332,7 @@ export function AdminQuotaPage({ notify }: { notify: Notify }) { {loading ? ( -

加载中…

// fetching policies

+ ) : policies.length === 0 ? (

暂无额度策略

// 默认仅余额管控 · 点右上新建

) : ( diff --git a/core/frontend/src/routes/admin/admin-governance.tsx b/core/frontend/src/routes/admin/admin-governance.tsx index ea9040b..4f0905b 100644 --- a/core/frontend/src/routes/admin/admin-governance.tsx +++ b/core/frontend/src/routes/admin/admin-governance.tsx @@ -2,6 +2,7 @@ import { useCallback, useEffect, useState } from "react"; import { adminApi } from "../../api"; import { Pager } from "../../components/pager"; import { IconKitSvg } from "../../components/IconKitSvg"; +import { SystemLoading } from "../../components/loading"; import type { AdminIntegrity, AdminProjectRow } from "../../types"; type Notify = (type: "success" | "error" | "info", text: string) => void; @@ -107,7 +108,7 @@ export function AdminGovernancePage({ notify }: { notify: Notify }) { {loading ? ( -

加载中…

// fetching projects

+ ) : projects.length === 0 ? (

暂无项目

// no projects

) : ( diff --git a/core/frontend/src/routes/admin/admin-invites.tsx b/core/frontend/src/routes/admin/admin-invites.tsx index 2ca51e5..a0c1cb3 100644 --- a/core/frontend/src/routes/admin/admin-invites.tsx +++ b/core/frontend/src/routes/admin/admin-invites.tsx @@ -3,6 +3,7 @@ import { Ticket, X } from "lucide-react"; import { adminApi } from "../../api"; import { Pager } from "../../components/pager"; import { IconKitSvg } from "../../components/IconKitSvg"; +import { SystemLoading } from "../../components/loading"; import type { Invitation } from "../../types"; type Notify = (type: "success" | "error" | "info", text: string) => void; @@ -129,7 +130,7 @@ export function AdminInvitesPage({ notify }: { notify: Notify }) { {loading ? ( -

加载中…

// fetching invitations

+ ) : invites.length === 0 ? (
diff --git a/core/frontend/src/routes/admin/admin-models.tsx b/core/frontend/src/routes/admin/admin-models.tsx index 97374be..5ac21c2 100644 --- a/core/frontend/src/routes/admin/admin-models.tsx +++ b/core/frontend/src/routes/admin/admin-models.tsx @@ -2,7 +2,7 @@ import { useCallback, useEffect, useState } from "react"; import { Server, X } from "lucide-react"; import { adminApi } from "../../api"; import { Pager } from "../../components/pager"; -import { IconKitSvg } from "../../components/IconKitSvg"; +import { SystemLoading } from "../../components/loading"; import type { AdminModel, AdminProvider } from "../../types"; import { pts } from "../stage-config"; @@ -128,7 +128,7 @@ export function AdminModelsPage({ notify }: { notify: Notify }) {
{loading ? ( -

加载中…

// fetching providers

+ ) : ( <>
供应商
diff --git a/core/frontend/src/routes/admin/admin-prompts.tsx b/core/frontend/src/routes/admin/admin-prompts.tsx index dc2d31a..cc5697a 100644 --- a/core/frontend/src/routes/admin/admin-prompts.tsx +++ b/core/frontend/src/routes/admin/admin-prompts.tsx @@ -1,6 +1,6 @@ import { useCallback, useEffect, useState } from "react"; import { adminApi } from "../../api"; -import { IconKitSvg } from "../../components/IconKitSvg"; +import { SystemLoading } from "../../components/loading"; import type { AdminPromptTemplate } from "../../types"; type Notify = (type: "success" | "error" | "info", text: string) => void; @@ -68,7 +68,7 @@ export function AdminPromptsPage({ notify }: { notify: Notify }) { {loading ? ( -

加载中…

// fetching prompt templates

+ ) : (
{rows.map((r) => { diff --git a/core/frontend/src/routes/admin/admin-quality.tsx b/core/frontend/src/routes/admin/admin-quality.tsx index a35a829..6df07fc 100644 --- a/core/frontend/src/routes/admin/admin-quality.tsx +++ b/core/frontend/src/routes/admin/admin-quality.tsx @@ -1,7 +1,7 @@ import { useCallback, useEffect, useState } from "react"; import { X } from "lucide-react"; import { adminApi } from "../../api"; -import { IconKitSvg } from "../../components/IconKitSvg"; +import { SystemLoading } from "../../components/loading"; import type { AdminQualityWord } from "../../types"; type Notify = (type: "success" | "error" | "info", text: string) => void; @@ -84,7 +84,7 @@ export function AdminQualityPage({ notify }: { notify: Notify }) {
{loading ? ( -

加载中…

// fetching quality words

+ ) : (
{STAGES.map((s) => { diff --git a/core/frontend/src/routes/admin/admin-reviews.tsx b/core/frontend/src/routes/admin/admin-reviews.tsx index 6df46f8..dd94660 100644 --- a/core/frontend/src/routes/admin/admin-reviews.tsx +++ b/core/frontend/src/routes/admin/admin-reviews.tsx @@ -3,6 +3,8 @@ import { RefreshCw } from "lucide-react"; import { adminApi } from "../../api"; import { Pager } from "../../components/pager"; import { IconKitSvg } from "../../components/IconKitSvg"; +import { SystemLoading } from "../../components/loading"; +import { MediaLightbox } from "../../components/overlays"; import type { AdminReviewAsset } from "../../types"; type Notify = (type: "success" | "error" | "info", text: string) => void; @@ -32,6 +34,7 @@ export function AdminReviewsPage({ notify }: { notify: Notify }) { const [tab, setTab] = useState(""); const [selected, setSelected] = useState>(new Set()); const [busy, setBusy] = useState(false); + const [preview, setPreview] = useState<{ src: string; name: string } | null>(null); const load = useCallback(async () => { setLoading(true); @@ -113,7 +116,7 @@ export function AdminReviewsPage({ notify }: { notify: Notify }) {
{loading ? ( -

加载中…

// fetching review queue

+ ) : assets.length === 0 ? (

暂无资产

// no person assets

) : ( @@ -135,7 +138,17 @@ export function AdminReviewsPage({ notify }: { notify: Notify }) { toggleOne(a.id)} aria-label="选择" /> {a.preview_url - ? {a.name} + ? ( + + ) : } {a.team_name || } @@ -160,12 +173,14 @@ export function AdminReviewsPage({ notify }: { notify: Notify }) { )} {selected.size > 0 && ( -
- 已选 {selected.size} 个 +
+ 已选 {selected.size} 个
)} + + setPreview(null)} /> ); } diff --git a/core/frontend/src/routes/admin/admin-tasks.tsx b/core/frontend/src/routes/admin/admin-tasks.tsx index e9e6c76..72c3db1 100644 --- a/core/frontend/src/routes/admin/admin-tasks.tsx +++ b/core/frontend/src/routes/admin/admin-tasks.tsx @@ -3,6 +3,7 @@ import { Activity, X } from "lucide-react"; import { adminApi } from "../../api"; import { Pager } from "../../components/pager"; import { IconKitSvg } from "../../components/IconKitSvg"; +import { SystemLoading } from "../../components/loading"; import type { AdminTask, AdminTaskDetail } from "../../types"; import { pts } from "../stage-config"; @@ -127,7 +128,7 @@ export function AdminTasksPage({ notify }: { notify: Notify }) {
{loading ? ( -

加载中…

// fetching tasks

+ ) : tasks.length === 0 ? (

暂无任务

// no tasks

) : ( @@ -174,7 +175,7 @@ export function AdminTasksPage({ notify }: { notify: Notify }) {
{detailLoading || !detail ? ( -

加载中…

+ ) : ( <> {(() => { diff --git a/core/frontend/src/routes/admin/admin-teams-users.tsx b/core/frontend/src/routes/admin/admin-teams-users.tsx index 78ef346..079dfc9 100644 --- a/core/frontend/src/routes/admin/admin-teams-users.tsx +++ b/core/frontend/src/routes/admin/admin-teams-users.tsx @@ -3,6 +3,7 @@ import { Building, KeyRound, Percent, X } from "lucide-react"; import { adminApi } from "../../api"; import { Pager } from "../../components/pager"; import { IconKitSvg } from "../../components/IconKitSvg"; +import { SystemLoading } from "../../components/loading"; import type { AdminTeam, AdminTeamDetail, AdminUser } from "../../types"; import { pts } from "../stage-config"; @@ -128,7 +129,7 @@ export function AdminTeamsPage({ notify }: { notify: Notify }) {
{loading ? ( -

加载中…

// fetching teams

+ ) : teams.length === 0 ? (

暂无团队

// no teams

) : ( @@ -198,7 +199,7 @@ export function AdminTeamsPage({ notify }: { notify: Notify }) {
{detailLoading || !detail ? ( -

加载中…

+ ) : ( <>
@@ -311,7 +312,7 @@ export function AdminUsersPage({ notify }: { notify: Notify }) {
{loading ? ( -

加载中…

// fetching users

+ ) : users.length === 0 ? (

暂无用户

// no users

) : ( diff --git a/core/frontend/src/routes/ai-tools.tsx b/core/frontend/src/routes/ai-tools.tsx index 3a598f4..2477e99 100644 --- a/core/frontend/src/routes/ai-tools.tsx +++ b/core/frontend/src/routes/ai-tools.tsx @@ -31,7 +31,7 @@ import type { AITask, Asset, ImageConversation, ImageConversationTask, ModelConf import { api } from "../api"; import { imageModelPickerOptions } from "../model-display"; import { ModelLibrary } from "../components/model-library"; -import { SkeletonRows } from "../components/loading"; +import { SkeletonRows, SystemLoading } from "../components/loading"; import { ConfirmModal, MediaLightbox } from "../components/overlays"; import { Pager } from "../components/pager"; import { useViewMode } from "../components/use-view-mode"; @@ -461,7 +461,7 @@ export function AssetFactoryPage({ navigate }: { navigate: (page: Page) => void
{batchLoading ? ( -
// LOADING…
+ ) : batchImgs.length === 0 ? (
// NO IMAGE
这批没有可显示的图片
) : ( diff --git a/core/frontend/src/routes/free-create.tsx b/core/frontend/src/routes/free-create.tsx index 4d928d4..4848932 100644 --- a/core/frontend/src/routes/free-create.tsx +++ b/core/frontend/src/routes/free-create.tsx @@ -2,7 +2,7 @@ // 任务流(滚动加载)+ 底部输入条(全能参考/首尾帧 + @mention)+ 渐进轮询(10/30/60s 打后端 // poll 端点,web 进程内查火山,不依赖 worker)+ 平滑进度动画(sessionStorage 续)+ 全屏播放器。 import { useCallback, useEffect, useMemo, useRef, useState } from "react"; -import { Users } from "lucide-react"; +import { Images, Users } from "lucide-react"; import { api, ApiError } from "../api"; import type { FreeVideoRef, FreeVideoTask, ModelConfig } from "../types"; import { @@ -23,7 +23,9 @@ import { FreeInputBar } from "../components/free-create/input-bar"; import { GenerationCard } from "../components/free-create/generation-card"; import { VideoDetailModal } from "../components/free-create/video-detail-modal"; import { AssetLibraryModal } from "../components/free-create/asset-library-modal"; +import { PlatformLibraryModal } from "../components/free-create/platform-library-modal"; import type { PromptInputHandle } from "../components/free-create/prompt-input"; +import { SystemLoading } from "../components/loading"; import { ConfirmModal } from "../components/overlays"; const PAGE_SIZE = 20; @@ -96,6 +98,7 @@ export function FreeCreatePage({ modelConfigs, onNotify, onTaskSettled }: { // —— 弹窗 —— const [detailId, setDetailId] = useState(null); const [libraryOpen, setLibraryOpen] = useState(false); + const [platformOpen, setPlatformOpen] = useState(false); const [libraryTargetRole, setLibraryTargetRole] = useState<"first_frame" | "last_frame" | null>(null); const [deleteTarget, setDeleteTarget] = useState(null); @@ -468,6 +471,21 @@ export function FreeCreatePage({ modelConfigs, onNotify, onTaskSettled }: { setLibraryTargetRole(null); }, []); + // 三库(资产库/模特库/商品库)引用:与人物素材库共用槽位逻辑,只是数据源不同。 + // 首尾帧模式没指定槽位时按拖拽同一规则落位:首帧空就填首帧,否则填尾帧。 + const openPlatformLibrary = useCallback((role?: "first_frame" | "last_frame") => { + const target = mode === "keyframe" + ? role || (refs.some((r) => r.role === "first_frame") ? "last_frame" : "first_frame") + : null; + setLibraryTargetRole(target); + setPlatformOpen(true); + }, [mode, refs]); + + const closePlatformLibrary = useCallback(() => { + setPlatformOpen(false); + setLibraryTargetRole(null); + }, []); + // 素材库选中 → 全能参考插 mention;首尾帧写入对应槽位 const handleLibraryPick = useCallback((ref: FreeVideoRef) => { if (mode === "keyframe") { @@ -483,8 +501,7 @@ export function FreeCreatePage({ modelConfigs, onNotify, onTaskSettled }: { ...ref, key: nextRefKey(), role: libraryTargetRole, - label: undefined, - source: "library" + label: undefined }; setRefs((prev) => [...prev.filter((r) => r.role !== libraryTargetRole), local]); setLibraryTargetRole(null); @@ -517,6 +534,9 @@ export function FreeCreatePage({ modelConfigs, onNotify, onTaskSettled }: {
+ @@ -525,7 +545,7 @@ export function FreeCreatePage({ modelConfigs, onNotify, onTaskSettled }: {
{loading ? ( -
// 加载中…
+ ) : tasks.length === 0 ? (
@@ -572,6 +592,7 @@ export function FreeCreatePage({ modelConfigs, onNotify, onTaskSettled }: { onDurationChange={setDuration} onSeedChange={setSeed} onOpenLibrary={openLibrary} + onOpenPlatformLibrary={openPlatformLibrary} onClear={clearInput} onSend={() => void handleSend()} /> @@ -593,6 +614,14 @@ export function FreeCreatePage({ modelConfigs, onNotify, onTaskSettled }: { + + {loading ? ( -
// 加载中…
+ ) : items.length === 0 ? (
// 还没有模特 diff --git a/core/frontend/src/routes/pipeline.tsx b/core/frontend/src/routes/pipeline.tsx index 69de39c..c4de33c 100644 --- a/core/frontend/src/routes/pipeline.tsx +++ b/core/frontend/src/routes/pipeline.tsx @@ -12,13 +12,32 @@ import { MediaLightbox, useBodyScrollLock } from "../components/overlays"; import { ModelLibrary } from "../components/model-library"; import { ReviewBadge, type ReviewStatus } from "../components/review-badge"; import { IconKitSvg } from "../components/IconKitSvg"; +import { + allowedStructures, + clampDuration, + DURATION_OPTIONS, + durationWarning, + isForbidden, + PRESENTATION_FORMATS, + PRESENTATION_HINT, + PRESENTATION_KEYS, + recommendDuration, + recommendSetup, + SEGMENT_DURATION_MAX, + STRUCTURE_HINT, + TOTAL_DURATION_MIN, + VIDEO_STRUCTURES, + type PresentationFormat, + type VideoStructure, +} from "../script-setup"; // 真实资产缩略图注入:与全站一致用 --mock-media-url(.placeholder.has-mock-media 负责 cover 裁切 + 8px 圆角) const mediaStyle = (url: string): CSSProperties => ({ ["--mock-media-url"]: `url(${url})` } as CSSProperties); // 基础资产组 kind → 中文区块名(期3:视频项目资产趴「人物」→「角色」,可引用模特库的可复用形象) const KIND_LABEL: Record = { product: "商品", person: "角色", scene: "场景" }; // 脚本来源 → 「来源」brief pill 文案 -const SOURCE_LABEL: Record = { ai: "AI 全生", theme: "一句话主题", manual: "自带脚本" }; +// 入口收敛为「脚本辅助生成 / 上传脚本」两种。theme 键保留:历史稿的 source 仍可能是旧的「一句话主题」。 +const SOURCE_LABEL: Record = { ai: "脚本辅助生成", theme: "脚本辅助生成", manual: "上传脚本", video: "上传视频提炼" }; // 旁白配音音色预设(语音合成经典版试用包实测可用,与后端 VOICEOVER_VOICES 对齐) const VO_VOICES = [ { key: "BV700_streaming", label: "灿灿 · 活力女声" }, @@ -28,9 +47,8 @@ const VO_VOICES = [ { key: "BV102_streaming", label: "儒雅青年 · 解说男声" }, { key: "BV002_streaming", label: "通用男声" }, ]; -// 新建向导落进 metadata.wizard 的是选项 key,这里映射回中文(对齐 projects.tsx 的 WIZ_STYLES/WIZ_PERSONAS) -// 脚本风格预设(顺序对齐设计稿设定卡下拉:真实测评 / 痛点种草 / 小红书种草 / 开箱测评 / 对比展示) -const WIZ_STYLE_LABEL: Record = { real: "真实测评", pain: "痛点种草", xhs: "小红书种草", review: "开箱测评", compare: "对比展示" }; +// 新建向导落进 metadata.wizard 的是选项 key,这里映射回中文(对齐 projects.tsx 的 WIZ_PERSONAS) +// 一期的「风格」(真实测评/痛点种草/…)已被二期的「视频结构」取代,见 script-setup.ts const WIZ_PERSONA_LABEL: Record = { urban: "都市白领女性", bestie: "闺蜜种草", ceo: "总裁亲选", reviewer: "专业测评师", mom: "实用宝妈", genz: "学生党" }; // 视频片段状态 pill 文案(色调走 statusPill:ok/info/err/neutral) function statusLabel(status: string): string { @@ -570,6 +588,9 @@ export function PipelinePage(props: { try { return new Set(JSON.parse(localStorage.getItem(`airshelf:pending-del:${project.id}`) || "[]")); } catch { return new Set(); } }); const shots = [...(currentScript?.segments ?? [])].sort((a, b) => a.sort_order - b.sort_order).filter((s) => !pendingDeletes.has(s.id)); + // 单镜时长自二期起是变长的(4–15s),缺值时按上限兜底,跟后端 script_agent 一致 + const shotSeconds = (s: { duration_seconds?: number | null }) => s.duration_seconds || SEGMENT_DURATION_MAX; + const scriptTotalSeconds = shots.reduce((sum, s) => sum + shotSeconds(s), 0); // 对白 speaker(entity id)→ 角色名;null = 旁白。entities 在 ScriptVersion.metadata const scriptEntities = ((currentScript?.metadata as { entities?: Array<{ id: string; name: string }> } | undefined)?.entities) ?? []; const entityName = (id: string | null | undefined) => (id ? (scriptEntities.find((e) => e.id === id)?.name || id) : "旁白"); @@ -661,9 +682,9 @@ export function PipelinePage(props: { // 每次生成=一个 product group=一版三视图;预览态(triPreviewId)只切主图,采用态存 metadata.product_tri_group const [triPanelOpen, setTriPanelOpen] = useState(false); const [triPreviewId, setTriPreviewId] = useState(null); - // 通用「选已有素材替换」选图层。商品三视图(取 product_image、按商品过滤)与场景(取 scene、团队全量)共用一套。 - // 角色不走这里——角色有专门的模特选择器(含模特库/角色三视图工作台),对角色是对的。 - // 旧 bug:场景卡「替换」误用了模特选择器 → 列的是角色数据。现在场景改走本选图层。 + // 通用「选已有素材替换」选图层。当前由场景卡使用(取 scene、团队全量);按商品过滤的 product_image 分支留给后续复用。 + // 商品三视图不走这里——它已改成卡片上的独立上传框 + AI 生成两条通道(见 uploadTriViewFromFile)。 + // 角色也不走这里——角色有专门的模特选择器(含模特库/角色三视图工作台),对角色是对的。 type AssetPick = { title: string; category: "product_image" | "scene"; @@ -731,6 +752,8 @@ export function PipelinePage(props: { setAssetPickBusy(false); } } + const triFileRef = useRef(null); + const [triUploading, setTriUploading] = useState(false); function jumpAssetSection(kind: "product" | "person" | "scene") { setAssetTab(kind); if (typeof document !== "undefined") { @@ -1150,7 +1173,7 @@ export function PipelinePage(props: { const timeline = project.timeline; const tlTotalMs = (timeline?.duration_seconds || 0) * 1000; const tlClips = [...(timeline?.clips ?? [])].sort((a, b) => a.sort_order - b.sort_order); - const tlRulerMs = tlTotalMs > 0 ? tlTotalMs : tlClips.reduce((m, c) => Math.max(m, c.start_ms + c.duration_ms), 0) || 15000; + const tlRulerMs = tlTotalMs > 0 ? tlTotalMs : tlClips.reduce((m, c) => Math.max(m, c.start_ms + c.duration_ms), 0) || scriptTotalSeconds * 1000 || TOTAL_DURATION_MIN * 1000; const ruler = buildRuler(tlRulerMs / 1000); const subtitleTrack = (timeline?.subtitle_tracks ?? []).find((t) => t.enabled) || (timeline?.subtitle_tracks ?? [])[0] || null; const subtitleCues = [...(subtitleTrack?.content ?? [])].sort((a, b) => a.start_ms - b.start_ms); @@ -1234,24 +1257,72 @@ export function PipelinePage(props: { const [chatText, setChatText] = useState(""); // 媒体预览灯箱(视频片段播放 / 故事板分镜放大) const [preview, setPreview] = useState<{ src: string; kind: "image" | "video"; name: string } | null>(null); - const [chatMode, setChatMode] = useState<"ai" | "theme" | "manual">("ai"); + const [chatMode, setChatMode] = useState<"ai" | "manual" | "video">("ai"); const [chatAttachments, setChatAttachments] = useState>([]); - // ── Stage 1 · 生成前「来源 & 风格 & 人物」设定(行28/行30):向导那边已删,这里补上 ── - // setupOpen:三选项之一被选中后,展示风格/人物下拉 + 确认/重新推荐;确认后才真正发起生成。 - const SETUP_STYLE_KEYS = Object.keys(WIZ_STYLE_LABEL); + // ── Stage 1 · 生成前「表现形式 × 视频结构 × 人物 × 时长」设定(1.5–1.9):向导那边已删,这里补上 ── + // setupOpen:两个入口之一被选中后,展示四栏选择 + 确认/重新推荐;确认后才真正发起生成。 const SETUP_PERSONA_KEYS = Object.keys(WIZ_PERSONA_LABEL); + // 2.3 按商品品类/人群推荐一组默认值;用户随时可改,推荐只是省掉「从零开始选」 + const setupProduct = products.find((item) => item.id === project.product); + const recommended = useMemo( + () => recommendSetup({ + category: setupProduct?.category, + targetAudience: setupProduct?.target_audience, + title: setupProduct?.title, + }), + [setupProduct?.category, setupProduct?.target_audience, setupProduct?.title] + ); + const wizard = project.metadata?.wizard; const [setupOpen, setSetupOpen] = useState(false); - const [setupSource, setSetupSource] = useState<"ai" | "theme" | "manual">("ai"); - const [setupStyle, setSetupStyle] = useState(project.metadata?.wizard?.script_style || SETUP_STYLE_KEYS[0] || "pain"); - const [setupPersona, setSetupPersona] = useState(project.metadata?.wizard?.persona || SETUP_PERSONA_KEYS[0] || "urban"); - // 向导选的成片时长档("0-30"→30 秒)传给脚本 agent —— 脚本按它定镜数,别再写死 60(否则选 30s 也按 60s 出) - const wizardTotalDuration = (() => { - const raw = project.metadata?.wizard?.duration; - const m = /(\d+)\s*$/.exec(typeof raw === "string" ? raw : ""); - return m ? Number(m[1]) : 60; - })(); + const [setupSource, setSetupSource] = useState<"ai" | "manual" | "video">("ai"); + const [setupFormat, setSetupFormat] = useState( + (wizard?.presentation_format as PresentationFormat) || recommended.format + ); + const [setupStructure, setSetupStructure] = useState( + (wizard?.video_structure as VideoStructure) || recommended.structure + ); + const [setupPersona, setSetupPersona] = useState(wizard?.persona || SETUP_PERSONA_KEYS[0] || "urban"); + const [setupDuration, setSetupDuration] = useState(() => { + if (typeof wizard?.total_duration === "number") return clampDuration(wizard.total_duration); + // 兼容一期向导存的字符串档位("0-30" → 30) + const legacy = /(\d+)\s*$/.exec(typeof wizard?.duration === "string" ? wizard.duration : ""); + return legacy ? clampDuration(Number(legacy[1])) : recommended.duration; + }); + // 商品是异步拉回来的,首帧 setupProduct 还是 undefined → 上面的初值只能拿到兜底组合。 + // 等商品到位后补一次推荐,但只在「用户没动过 + 项目也没存过设定」时才覆盖。 + const setupTouched = useRef(false); + const wizardHasCombo = Boolean(wizard?.presentation_format && wizard?.video_structure); + const wizardHasDuration = typeof wizard?.total_duration === "number" || Boolean(wizard?.duration); + useEffect(() => { + if (setupTouched.current || wizardHasCombo) return; + setSetupFormat(recommended.format); + setSetupStructure(recommended.structure); + if (!wizardHasDuration) setSetupDuration(recommended.duration); + }, [recommended.format, recommended.structure, recommended.duration, wizardHasCombo, wizardHasDuration]); + // 1.8 组合联动:表现形式为主,视频结构按它筛;当前选中的若被筛掉就自动落到第一个合法项 + const structureOptions = useMemo(() => allowedStructures(setupFormat), [setupFormat]); + function pickFormat(next: PresentationFormat) { + setupTouched.current = true; + setSetupFormat(next); + if (isForbidden(next, setupStructure)) setSetupStructure(allowedStructures(next)[0]); + } + const durationHint = durationWarning(setupStructure, setupDuration); + // 建议时长跟着**当前选中**的组合走,不是跟着推荐组合走(否则选了短剧还提示口播的 30 秒) + const durationSuggest = recommendDuration(setupFormat, setupStructure); const chatTextareaRef = useRef(null); + // 输入框随内容长高(封顶 260px 后内部滚动)。上传脚本 / 上传视频提炼灌进来的是整篇稿子, + // 固定两行的框没法逐镜校对 —— 而「可人工逐镜编辑」正是这两个入口的硬要求。 + useEffect(() => { + const el = chatTextareaRef.current; + if (!el) return; + el.style.height = "auto"; + el.style.height = `${Math.min(el.scrollHeight, 260)}px`; + }, [chatText]); const chatFileRef = useRef(null); + const [scriptFileBusy, setScriptFileBusy] = useState(false); // 脚本文件读取中(docx 走后端,有网络往返) + // 1.11 上传视频提炼:抽帧 + 读帧要 30~60 秒,比读 docx 慢一个量级,单独一个 busy 位并在聊天区留一条进度消息 + const videoFileRef = useRef(null); + const [videoDigestBusy, setVideoDigestBusy] = useState(false); const chatBodyRef = useRef(null); // 对话记录(本地会话态):生成动作可追溯,不再是「点了按钮、对话区永远空着」 // kind=progress:进度提示流(行33),steps 逐条滚动出现,done 后折叠成一行结果 @@ -1432,8 +1503,12 @@ export function PipelinePage(props: { model_config_id: activeScriptModelId || undefined, base_version_id: baseVersionId, aspect_ratio: "9:16", - total_duration: wizardTotalDuration, - target_index: targetIndex + // 设定卡的三个参数一路传到后端:后端据此挑套路提示词 + 按节奏切镜(不再每镜写死 15 秒) + total_duration: setupDuration, + presentation_format: setupFormat, + video_structure: setupStructure, + target_index: targetIndex, + source: source === "manual" || source === "video" ? source : undefined }, (evt) => { receivedEvent = true; @@ -1517,20 +1592,40 @@ export function PipelinePage(props: { pushMsg("ai", summaryText || "镜头脚本已生成,左侧已刷新。可继续输入修改意见,或点「确认脚本」进入下一步。"); } } - // 行28/行30 · 确认设定后真正发起生成:把所选「风格 / 人物」并进提示词(后端从 prompt 推断) + // 1.5 · 确认设定后真正发起生成。表现形式/结构/时长走结构化参数(后端挑套路), + // 人物没有对应的后端字段,仍并进 prompt 让模型读。 async function runScriptWithSetup() { - const styleLabel = WIZ_STYLE_LABEL[setupStyle] || setupStyle; + const formatLabel = PRESENTATION_FORMATS[setupFormat]; + const structureLabel = VIDEO_STRUCTURES[setupStructure]; const personaLabel = WIZ_PERSONA_LABEL[setupPersona] || setupPersona; - // 行28 · 持久化所选风格/人物到 metadata.wizard。先 await 落库(设定卡仍开着、确定 disabled), - // 存完再「关卡」一帧切换,不出现空窗 → 不闪回三个菜单。 - await onSaveProjectMeta?.({ wizard: { ...(project.metadata?.wizard ?? {}), script_style: setupStyle, persona: setupPersona } }); + // 持久化到 metadata.wizard。先 await 落库(设定卡仍开着、确定 disabled), + // 存完再「关卡」一帧切换,不出现空窗 → 不闪回入口菜单。 + await onSaveProjectMeta?.({ + wizard: { + ...(project.metadata?.wizard ?? {}), + presentation_format: setupFormat, + video_structure: setupStructure, + total_duration: setupDuration, + persona: setupPersona, + }, + }); setSetupOpen(false); - const sourceLabel = SOURCE_LABEL[setupSource] || "AI 全生"; - if (setupSource === "theme") { - // 确定 = 确认风格/人物;主题交给模型来问 → 用户输入后由 submitChat 的 theme 分支带风格/人物生成 - setChatMode("theme"); - pushMsg("ai", `好,${styleLabel} · ${personaLabel} 定了。用一句话说说这条视频的主题(5-30字),例如「熬夜党的早八续命面膜」,发我就生成。`); - chatTextareaRef.current?.focus(); + const combo = `${formatLabel} · ${structureLabel} · ${setupDuration}s · ${personaLabel}`; + if (setupSource === "video") { + const base = chatText.trim(); + if (!base) { + chatTextareaRef.current?.focus(); + return; + } + setChatText(""); + // 上传视频提炼:参考的是**结构与节奏**,画面里的商品是别人的,必须换成用户自己的商品重写 + await runScriptGeneration( + `${base}\n以上是我拆解一条参考视频得到的分镜稿。请照搬它的叙事结构、镜头节奏和每镜的作用顺序,` + + `把内容整体换成我自己的商品重写一遍;参考视频里出现的商品、品牌、人物一律不要保留。` + + `原稿里标注「看不清 / 缺失 / 存疑」的地方,由你按我的商品补全。目标人群:${personaLabel}`, + `上传视频提炼 · ${combo}`, + "video", + ); return; } if (setupSource === "manual") { @@ -1540,10 +1635,21 @@ export function PipelinePage(props: { return; } setChatText(""); - await runScriptGeneration(`${base ? `${base}\n` : ""}据此整理成镜头脚本。风格:${styleLabel},目标人群:${personaLabel}`, `自带脚本 · ${styleLabel} · ${personaLabel}`, "manual"); + // 上传脚本:先识别原稿结构,再补齐钩子 / 卖点证明 / 转化,而不是照抄一遍 + await runScriptGeneration( + `${base}\n以上是我已有的脚本。请先识别它的结构与叙述顺序,尽量保留原意与原有表达,再据此整理成镜头脚本;` + + `其中开头钩子、卖点的证明方式、结尾转化引导如有缺失或偏弱,请补齐补强。目标人群:${personaLabel}`, + `上传脚本 · ${combo}`, + "manual", + ); return; } - await runScriptGeneration(`AI 全生 · 风格:${styleLabel},目标人群:${personaLabel}。突出商品卖点,节奏紧凑,适合短视频投放`, `${sourceLabel}:${styleLabel} · ${personaLabel}`, "ai"); + const sourceLabel = SOURCE_LABEL[setupSource] || "脚本辅助生成"; + await runScriptGeneration( + `目标人群:${personaLabel}。突出商品卖点,节奏紧凑,适合短视频投放`, + `${sourceLabel}:${combo}`, + "ai", + ); } // 流程步骤3 · 把待确认的标签增删拼成给 AI 的重写指令 + 用户消息标签 function tagEditsInstruction() { @@ -1558,15 +1664,6 @@ export function PipelinePage(props: { const text = chatText.trim(); const tagInstr = tagEditsInstruction(); if (!text && !tagInstr) return; - // 一句话主题:确定后模型已问主题,这次输入就是主题 → 带上已确认的风格/人物生成(仅首次出稿前) - if (chatMode === "theme" && !currentScript && text) { - const styleLabel = WIZ_STYLE_LABEL[setupStyle] || setupStyle; - const personaLabel = WIZ_PERSONA_LABEL[setupPersona] || setupPersona; - setChatText(""); - setChatAttachments([]); - void runScriptGeneration(`一句话主题:${text}。风格:${styleLabel},目标人群:${personaLabel}。生成镜头脚本,突出商品卖点,适合短视频投放`, `一句话主题:${text}`, "theme"); - return; - } const prompt = [tagInstr, text].filter(Boolean).join("\n"); const label = [pendingTagEdits.map(tagEditVerb).join(" · "), text].filter(Boolean).join(" · "); setChatText(""); @@ -1594,40 +1691,86 @@ export function PipelinePage(props: { setSetupOpen(false); setChatMsgs([]); } - // 行30 · 三选项指引:选了某种生成方式 → 打开「来源 & 风格 & 人物」设定卡(确认后再生成) - function openSetup(source: "ai" | "theme" | "manual") { + // 选了某种生成方式 → 打开「来源 & 风格 & 人物」设定卡(确认后再生成) + function openSetup(source: "ai" | "manual" | "video") { setSetupSource(source); setChatMode(source); setSetupOpen(true); - // 一句话主题:开卡时不再 push 提示(否则 chatMsgs 非空,点返回就回不到三选项); - // 主题改成在「确定」(确认风格/人物)之后由模型来问,更像对话流。 - if (source === "manual") { + if (source === "manual" || source === "video") { setChatText(""); setChatAttachments([]); + // 上传脚本 / 上传视频:选中即拉起选文件(用户手势内触发,不会被浏览器拦);想改成粘贴的话直接在输入框打字即可 + (source === "video" ? videoFileRef : chatFileRef).current?.click(); window.setTimeout(() => chatTextareaRef.current?.focus(), 0); } - // 文件上传入口先保留,当前「自带脚本」改为粘贴文本。 - // if (source === "manual") { pickScriptMode(); } } - function pickScriptMode() { - setChatMode("manual"); - chatFileRef.current?.click(); - } - function onPickScriptFile(event: ChangeEvent) { + // 1.11 上传视频提炼:后端抽帧读帧出分镜稿 → 填进输入框。 + // 稿子**故意落在可编辑的输入框里而不是直接出脚本**:AI 拆解必然有错,用户先逐镜改完再交给脚本 agent。 + async function onPickVideoFile(event: ChangeEvent) { const file = event.target.files?.[0]; event.target.value = ""; if (!file) return; - const reader = new FileReader(); - reader.onload = () => { - const text = String(reader.result || "").trim(); + if (!/\.(mp4|mov|m4v|webm)$/i.test(file.name)) { + onNotify?.("error", "只支持 mp4 / mov / m4v / webm 四种视频格式"); + return; + } + setVideoDigestBusy(true); + pushMsg("user", `已上传视频《${file.name}》`); + pushMsg("ai", "正在抽帧拆解这条视频,大约需要半分钟…"); + try { + const fd = new FormData(); + fd.append("file", file); + const digest = await api.extractScriptVideo(project.id, fd); + const text = digest.text.trim(); + setChatText((prev) => (prev ? `${prev}\n${text}` : text)); + setChatAttachments((list) => [...list, { name: file.name, chars: text.length }]); + setChatMode("video"); + pushMsg("ai", `已拆出 ${digest.duration} 秒 · 取了 ${digest.frames} 帧。拆解稿在下面输入框里,` + + "请先逐镜核对再点确定 —— 尤其是「拆解存疑」那几条。确认后我按你的商品把它改写成新脚本。"); + chatTextareaRef.current?.focus(); + } catch (error) { + pushMsg("ai", "这条视频没能拆开,可以换一条再试。"); + onNotify?.("error", error instanceof Error && error.message ? error.message : "视频拆解失败,请重试"); + } finally { + setVideoDigestBusy(false); + } + } + // 上传脚本仅收 docx / txt。txt 前端直读;docx 交后端拆(zip+xml),前端不引解析库。 + async function onPickScriptFile(event: ChangeEvent) { + const file = event.target.files?.[0]; + event.target.value = ""; + if (!file) return; + const name = file.name.toLowerCase(); + const isDocx = name.endsWith(".docx"); + if (!isDocx && !name.endsWith(".txt")) { + onNotify?.("error", "只支持 docx 和 txt 两种脚本文件"); + return; + } + setScriptFileBusy(true); + try { + let text = ""; + if (isDocx) { + const fd = new FormData(); + fd.append("file", file); + text = (await api.extractScriptFile(project.id, fd)).text.trim(); + } else { + text = (await file.text()).trim(); + } + if (!text) { + onNotify?.("error", "没能从这个文件里读到文字,请换一份"); + return; + } setChatText((prev) => (prev ? `${prev}\n${text}` : text)); setChatAttachments((list) => [...list, { name: file.name, chars: text.length }]); setChatMode("manual"); - pushMsg("user", `已上传脚本附件《${file.name}》(${text.length} 字)`); - pushMsg("ai", "已读入你的脚本。可在输入框补充修改意见,点发送据此生成镜头脚本。"); + pushMsg("user", `已上传脚本《${file.name}》(${text.length} 字)`); + pushMsg("ai", "已读入你的脚本。确认风格与人物后点「确定」,我会保留你的原意,并把钩子、卖点证明、转化引导补齐。"); chatTextareaRef.current?.focus(); - }; - reader.readAsText(file); + } catch (error) { + onNotify?.("error", error instanceof Error && error.message ? error.message : "读取脚本文件失败,请重试"); + } finally { + setScriptFileBusy(false); + } } // Stage 1 · 脚本助手宽度拖拽(对齐设计稿:clamp [380, 680],写 --chat-w) const scriptGridRef = useRef(null); @@ -2504,6 +2647,24 @@ export function PipelinePage(props: { const productName = productRecord?.title || "透真补水面膜"; const productCover = productRecord?.cover_asset || productRecord?.images?.find((img) => img.is_primary)?.asset || productRecord?.images?.[0]?.asset || null; + // 三视图的「上传」通道:独立上传框,不再借「替换」弹窗绕。AI 生成通道原样保留,两条路并存。 + async function uploadTriViewFromFile(file: File) { + setTriUploading(true); + try { + const fd = new FormData(); + fd.append("file", file); + fd.append("asset_type", "image"); + fd.append("category", "tri_view"); + fd.append("name", `${productName}·三视图`); + const asset = await api.uploadAsset(fd); // 落 TOS + 建团队资产 + await onAttachBaseAsset({ kind: "product" }, asset.id); // 直接采用为商品三视图 + } catch (e) { + onNotify?.("error", e instanceof Error && e.message ? e.message : "上传失败,请重试"); + } finally { + setTriUploading(false); + } + } + // 进资产趴【不再自动生成基础资产】——原先这里会自动 genPersonPortrait/genBaseAsset 出图、直接扣用户费, // 用户有"被掏口袋"感。改为:进资产趴盖半透明蒙版 + 三按钮(只提取 / 提取+生成角色场景 / 提取+全套), // 由用户点了才提取/生成(见下方 STAGE 2 的 extract gate 与 runExtract)。 @@ -2632,10 +2793,23 @@ export function PipelinePage(props: {
- {/* 真实创作方向:来源=已有脚本的 source(无脚本时跟随所选模式),风格/人物=新建向导里选的(metadata.wizard) */} - 来源{currentScript ? (SOURCE_LABEL[currentScript.source || "ai"] || "AI 全生") : chatMode !== "ai" ? SOURCE_LABEL[chatMode] : "未选择"} - 风格{(() => { const k = project.metadata?.wizard?.script_style; return k ? (WIZ_STYLE_LABEL[k] || k) : "待确认"; })()} - 人物{(() => { const k = project.metadata?.wizard?.persona; return k ? (WIZ_PERSONA_LABEL[k] || k) : "待确认"; })()} + {/* 真实创作方向:来源=已有脚本的 source(无脚本时跟随所选模式),其余=设定卡确认时存进 metadata.wizard 的 */} + 来源{currentScript ? (SOURCE_LABEL[currentScript.source || "ai"] || "脚本辅助生成") : setupOpen ? SOURCE_LABEL[setupSource] : "未选择"} + 形式{(() => { + const k = wizard?.presentation_format as PresentationFormat | undefined; + // 旧项目只有一期的「风格」,没有表现形式 —— 显示待确认,别硬套 + return k && PRESENTATION_FORMATS[k] ? PRESENTATION_FORMATS[k] : "待确认"; + })()} + 结构{(() => { + const k = wizard?.video_structure as VideoStructure | undefined; + return k && VIDEO_STRUCTURES[k] ? VIDEO_STRUCTURES[k] : "待确认"; + })()} + 时长{(() => { + // 有脚本时按各镜真实秒数加总(镜可以不等长了);没脚本就显示设定卡里选的 + const scripted = (currentScript?.segments || []).reduce((sum, s) => sum + (s.duration_seconds || 0), 0); + return `${scripted || setupDuration} 秒`; + })()} + 人物{(() => { const k = wizard?.persona; return k ? (WIZ_PERSONA_LABEL[k] || k) : "待确认"; })()}
{/* 行34/流程步骤3 · 人物 / 场景标签:AI 出稿自动提取;增删不立即生效,排进待确认队列, 点发送才整体重写。待删的灰删除线、待加的虚线高亮,均可在输入框上方胶囊撤销。 */} @@ -2687,7 +2861,7 @@ export function PipelinePage(props: { let cum = 0; return shots.map((shot, index) => { const start = cum; - cum += shot.duration_seconds || 15; + cum += shotSeconds(shot); const narration = (shot.narration || "").trim(); const visual = (shot.visual_prompt || "").trim(); // 旁白/画面历史数据可能同文(结构化解析前生成),画面框置空避免重复编辑同一段 @@ -2812,18 +2986,26 @@ export function PipelinePage(props: {
{msg.time}
))} - {/* 行28/行30 · 选定生成方式后的「来源 & 风格 & 人物」设定卡(确认后再生成) */} + {/* 1.5 · 选定生成方式后的「表现形式 × 视频结构 × 人物 × 时长」设定卡(确认后再生成) */} {setupOpen && (
-
{setupSource === "manual" ? "已选「自带脚本」。" : setupSource === "theme" ? "已选「一句话主题」。" : "我会根据商品信息直接生成第一版。"}先确认创作方向。
+
{setupSource === "manual" ? "已选「上传脚本」。" : setupSource === "video" ? "已选「上传视频提炼」。" : "我会根据商品信息直接生成第一版。"}先确认创作方向。
-
根据商品信息推荐
+
{PRESENTATION_HINT[setupFormat]}
+ {/* 1.8 组合联动:短剧下拉里没有「测评验证」—— 演出来的实测没有可信度 */} + +
{STRUCTURE_HINT[setupStructure]}
根据商品目标人群推荐
+ {/* 1.9 时长自由化:5–60 秒、5 秒一档;每镜切多长由后端按表现形式的节奏决定 */} + +
{durationHint || `${PRESENTATION_FORMATS[setupFormat]} × ${VIDEO_STRUCTURES[setupStructure]}建议 ${durationSuggest} 秒左右`}
- {/* ← 返回:收起设定卡,回到「AI全生/一句话主题/自带脚本」三选项页 */} + {/* ← 返回:收起设定卡,回到「脚本辅助生成 / 上传脚本」入口页 */} - - +
{nowHm()}
@@ -2851,9 +3043,10 @@ export function PipelinePage(props: {
选择一种生成方式开始
// 三种,由「最省事」到「最保真原意」
- - - + + + {/* 1.11 上传视频提炼:拆一条参考视频的分镜,改成自己的商品重拍 */} +
)} @@ -2878,7 +3071,7 @@ export function PipelinePage(props: { ))}
- - {/* 文件上传入口先保留,当前自带脚本改为粘贴文本。 - - */} + void onPickScriptFile(event)} /> + void onPickVideoFile(event)} />
- {/* 文件上传按钮先保留,当前自带脚本改为粘贴文本。 - + {/* 1.11 · 聊天已经开起来之后仍能提炼参考视频(入口菜单只在空态出现) */} + - */} {/* 模型选择小按钮(输入框下方,对齐 ChatGPT/Lovart)· 复用 restraint chip 下拉,向上展开 */} {textModels && textModels.length > 0 ? (
@@ -3051,27 +3249,33 @@ export function PipelinePage(props: {
{(project.created_at || "").slice(0, 10)} 创建
- {/* 与人物/场景卡页脚一致:hstack 左「使用已有素材」(ghost) + spacer + 右「AI 生成」(primary) */} + {/* 双通道:左「上传三视图」(ghost,用户自带图) + 右「AI 生成三视图」(primary,平台生成) */}
- {/* 问题5(a):不想用平台生成 → 直接用已有素材(选商品图 / 上传本地图)当三视图 */} - + - +
+ { const f = e.target.files?.[0]; if (f) void uploadTriViewFromFile(f); e.target.value = ""; }} /> {/* 行39 · 三视图预览面板(对齐原版 prod-preview):生成中转圈 → 主图(可放大)+ 重跑/采用 + 历史版本切换 */}
// 三视图预览 · {triGenerating ? "生成中 · 约 12s" : previewTriAsset ? (previewTriAsset === adoptedTriAsset ? "已采用,不满意可重跑" : "预览中(未采用)") : "待生成"}
{(() => { const u = candUrl(productGroup, previewTriAsset); - if (triGenerating && !u) { - return
生成中
; + if ((triGenerating || triUploading) && !u) { + return
{triUploading ? "上传中" : "生成中"}
; + } + // 空态即上传框:点一下直接选本地图当三视图(不占用右侧 AI 生成那条路) + if (!u) { + return ( +
triFileRef.current?.click()} onKeyDown={(e) => { if (e.key === "Enter" || e.key === " ") { e.preventDefault(); triFileRef.current?.click(); } }}> + + 上传三视图 +
+ ); } return ( -
setPreview({ src: u, kind: "image", name: `三视图` }) : undefined} onKeyDown={u ? (e) => { if (e.key === "Enter" || e.key === " ") { e.preventDefault(); setPreview({ src: u, kind: "image", name: "三视图" }); } } : undefined}> - {!u && // 暂无三视图} -
+
setPreview({ src: u, kind: "image", name: "三视图" })} onKeyDown={(e) => { if (e.key === "Enter" || e.key === " ") { e.preventDefault(); setPreview({ src: u, kind: "image", name: "三视图" }); } }} /> ); })()} {previewTriAsset && ( @@ -3302,7 +3506,7 @@ export function PipelinePage(props: { {viewStage === 3 && (() => { // 每场时间区间(累加脚本镜时长 → 「0~5s」) let cum = 0; - const sceneTimes = shots.map((s) => { const st = cum; cum += s.duration_seconds || 15; return `${st}~${cum}s`; }); + const sceneTimes = shots.map((s) => { const st = cum; cum += shotSeconds(s); return `${st}~${cum}s`; }); const shotImg = (s?: StoryboardShot | null) => frameUrl(s ? { asset: s.adopted_asset ?? "", asset_url: s.adopted_asset_url } : null); const shotBusy = (s: StoryboardShot) => sbGenerating || sbBusyShots.has(s.id) || ["queued", "running"].includes(s.status); const cardCount = sbShots.length || sbExpectedShots; @@ -3471,6 +3675,15 @@ export function PipelinePage(props: { {viewStage === 4 && (() => { const pct = segments.length ? Math.round((segDone / segments.length) * 100) : 0; const anyStarted = segments.some((s) => ["running", "succeeded", "queued"].includes(s.status)); + const segSeconds = segments.map((s) => s.target_duration_seconds).filter((n) => n > 0); + const segMin = segSeconds.length ? Math.min(...segSeconds) : 0; + const segMax = segSeconds.length ? Math.max(...segSeconds) : 0; + const segTotal = segSeconds.reduce((sum, n) => sum + n, 0); + const paceText = !segSeconds.length + ? "每场按脚本时长出片" + : segMin === segMax + ? `每场 Seedance ${segMin} 秒 · 全片 ${segTotal} 秒` + : `每场 Seedance ${segMin}–${segMax} 秒 · 全片 ${segTotal} 秒`; const statusText = !segments.length ? "暂无片段" : segDone === segments.length @@ -3483,7 +3696,7 @@ export function PipelinePage(props: {
视频生成 · {segDone} / {segments.length} 完成
-
// 每场 Seedance 约 15 秒 · {statusText}
+
// {paceText} · {statusText}
{pct}% diff --git a/core/frontend/src/routes/trash.tsx b/core/frontend/src/routes/trash.tsx index 2ffd2b9..dd07b84 100644 --- a/core/frontend/src/routes/trash.tsx +++ b/core/frontend/src/routes/trash.tsx @@ -1,6 +1,7 @@ import { useEffect, useMemo, useState } from "react"; import type { CSSProperties } from "react"; import { api } from "../api"; +import { SystemLoading } from "../components/loading"; import { ConfirmModal } from "../components/overlays"; import type { NavigateFn } from "./route-config"; import type { Asset, FreeVideoTask, ImageConversationTrash, ImageExceptionBatchTrash, ModelEntity, Product, Project } from "../types"; @@ -326,7 +327,7 @@ export function TrashPage({ onRestore, onPurge, onRestoreProducts, onPurgeProduc
{loading ? ( -
// 加载中...
+ ) : isEmpty ? (
// 垃圾桶是空的
) : ( diff --git a/core/frontend/src/script-setup.ts b/core/frontend/src/script-setup.ts new file mode 100644 index 0000000..44f9e6e --- /dev/null +++ b/core/frontend/src/script-setup.ts @@ -0,0 +1,133 @@ +/** 脚本设定卡的选项体系:表现形式 × 视频结构 × 时长(二期第2段)。 + * + * 这里的枚举、禁用组合、时长区间必须跟后端 apps/ai/script_agent.py 的常量保持一致 —— + * 后端会 coerce 兜底,但前端先挡住能避免用户选了一个又被悄悄改掉。 + */ + +export type PresentationFormat = "oral" | "drama" | "vlog"; +export type VideoStructure = "pain" | "contrast" | "review" | "scene"; + +export const PRESENTATION_FORMATS: Record = { + oral: "口播", + drama: "短剧", + vlog: "Vlog", +}; + +export const VIDEO_STRUCTURES: Record = { + pain: "痛点解决", + contrast: "前后对比", + review: "测评验证", + scene: "场景种草", +}; + +export const PRESENTATION_KEYS = Object.keys(PRESENTATION_FORMATS) as PresentationFormat[]; +export const STRUCTURE_KEYS = Object.keys(VIDEO_STRUCTURES) as VideoStructure[]; + +/** 一句话说清每个选项是什么,挂在下拉旁边当说明,别让用户猜。 */ +export const PRESENTATION_HINT: Record = { + oral: "一个人对镜头说,信息密度最高、最省制作", + drama: "有角色有冲突的微型剧,完播率最高", + vlog: "第一人称生活记录,广告感最低", +}; + +export const STRUCTURE_HINT: Record = { + pain: "先戳痛点再给解法,适用面最广", + contrast: "用前后差距说话,视觉冲击最强", + review: "当场举证验证,适合高客单价", + scene: "把商品放进向往的场景里种草", +}; + +/** 唯一禁用组合:短剧 × 测评验证 —— 演出来的实测没有可信度。 */ +const FORBIDDEN: ReadonlyArray<[PresentationFormat, VideoStructure]> = [["drama", "review"]]; + +export function isForbidden(format: PresentationFormat, structure: VideoStructure): boolean { + return FORBIDDEN.some(([f, s]) => f === format && s === structure); +} + +/** 1.8 组合联动:选定表现形式后,视频结构只剩这些可选。 */ +export function allowedStructures(format: PresentationFormat): VideoStructure[] { + return STRUCTURE_KEYS.filter((key) => !isForbidden(format, key)); +} + +// ── 时长:总时长 5–60 秒、5 秒步进;单镜 4–15 秒由后端切,前端只管总时长 ── +export const SEGMENT_DURATION_MIN = 4; +export const SEGMENT_DURATION_MAX = 15; +export const TOTAL_DURATION_MIN = 5; +export const TOTAL_DURATION_MAX = 60; +export const TOTAL_DURATION_STEP = 5; +export const DURATION_OPTIONS: number[] = Array.from( + { length: (TOTAL_DURATION_MAX - TOTAL_DURATION_MIN) / TOTAL_DURATION_STEP + 1 }, + (_, i) => TOTAL_DURATION_MIN + i * TOTAL_DURATION_STEP +); + +/** 表现形式推荐的默认总时长:口播短平快 / 短剧要装下三幕 / Vlog 要铺氛围。 */ +const FORMAT_DEFAULT_DURATION: Record = { oral: 30, drama: 45, vlog: 30 }; +/** 各结构能压到的最短总时长 —— 低于它证据或氛围就不成立了。 */ +const STRUCTURE_MIN_DURATION: Record = { pain: 15, contrast: 10, review: 20, scene: 20 }; + +export function recommendDuration(format: PresentationFormat, structure: VideoStructure): number { + return Math.max(FORMAT_DEFAULT_DURATION[format], STRUCTURE_MIN_DURATION[structure]); +} + +export function clampDuration(seconds: number): number { + if (!Number.isFinite(seconds)) return FORMAT_DEFAULT_DURATION.oral; + const stepped = Math.round(seconds / TOTAL_DURATION_STEP) * TOTAL_DURATION_STEP; + return Math.min(TOTAL_DURATION_MAX, Math.max(TOTAL_DURATION_MIN, stepped)); +} + +/** 时长低于该结构的最短可用值时给一句提醒(不拦,只提示)。 */ +export function durationWarning(structure: VideoStructure, seconds: number): string { + const min = STRUCTURE_MIN_DURATION[structure]; + if (seconds >= min) return ""; + return `${VIDEO_STRUCTURES[structure]}低于 ${min} 秒会压不住,建议调长`; +} + +// ── 2.3 按商品信息推荐默认组合 ── + +type Combo = { format: PresentationFormat; structure: VideoStructure }; + +/** 品类关键词 → 推荐组合。命中第一条即用,没命中走兜底。 */ +const CATEGORY_RULES: ReadonlyArray<{ keywords: string[]; combo: Combo }> = [ + { keywords: ["美妆", "护肤", "彩妆", "面膜", "精华", "洗护", "个护"], combo: { format: "oral", structure: "contrast" } }, + { keywords: ["保健", "营养", "膳食", "益生菌", "维生素"], combo: { format: "oral", structure: "review" } }, + { keywords: ["数码", "3c", "电子", "电器", "手机", "耳机", "相机", "工具"], combo: { format: "oral", structure: "review" } }, + { keywords: ["食品", "零食", "饮料", "咖啡", "茶", "生鲜", "酒"], combo: { format: "vlog", structure: "scene" } }, + { keywords: ["服饰", "女装", "男装", "服装", "鞋", "包", "配饰", "内衣"], combo: { format: "vlog", structure: "scene" } }, + { keywords: ["家居", "家纺", "收纳", "厨具", "清洁", "日用"], combo: { format: "drama", structure: "pain" } }, + { keywords: ["母婴", "宝宝", "儿童", "宠物"], combo: { format: "drama", structure: "pain" } }, + { keywords: ["户外", "露营", "运动", "健身"], combo: { format: "vlog", structure: "scene" } }, +]; + +const FALLBACK_COMBO: Combo = { format: "oral", structure: "pain" }; + +/** 按商品品类 + 人群推荐一组「表现形式 × 视频结构 × 时长」。纯启发式,用户随时可改。 */ +export function recommendSetup(input: { category?: string; targetAudience?: string; title?: string }): Combo & { + duration: number; + reason: string; +} { + const haystack = [input.category, input.title, input.targetAudience] + .filter(Boolean) + .join(" ") + .toLowerCase(); + + let combo = FALLBACK_COMBO; + let reason = "商品信息不足,先给一组最通用的"; + for (const rule of CATEGORY_RULES) { + const hit = rule.keywords.find((word) => haystack.includes(word.toLowerCase())); + if (hit) { + combo = rule.combo; + reason = `按「${hit}」品类推荐`; + break; + } + } + // 防御:规则表若被改出禁用组合,这里兜住 + const structure = isForbidden(combo.format, combo.structure) + ? allowedStructures(combo.format)[0] + : combo.structure; + return { + format: combo.format, + structure, + duration: recommendDuration(combo.format, structure), + reason, + }; +} diff --git a/core/frontend/src/types.ts b/core/frontend/src/types.ts index f52da1f..db1f56a 100644 --- a/core/frontend/src/types.ts +++ b/core/frontend/src/types.ts @@ -510,7 +510,17 @@ export type Project = { storyboard_shots: StoryboardShot[]; timeline: Timeline | null; metadata?: { - wizard?: { duration?: string; script_style?: string; persona?: string; selling_point_ids?: string[] }; + wizard?: { + duration?: string; + script_style?: string; + persona?: string; + selling_point_ids?: string[]; + // 二期:表现形式 × 视频结构 × 总时长(秒)。script_style 是一期的「风格」,已被 video_structure 取代, + // 保留只为读旧项目的历史值,新稿不再写它。 + presentation_format?: string; + video_structure?: string; + total_duration?: number; + }; cast?: string[]; scenes?: string[]; // 流程步骤3/4 · 脚本提取出的每个人物/场景的建议生图提示词(基础资产据此 seed 卡片) @@ -590,8 +600,10 @@ export type FreeVideoRef = { label?: string; // @mention 引用名 thumb_url?: string; duration?: number; // 视频/音频时长(秒) - asset_id?: string; // 直传素材的 Asset id / 素材库 FreeAsset id - source?: "upload" | "library" | "library_group"; + // asset_id 的含义随 source 变:upload/asset → 平台 Asset id;library → 人物素材库 FreeAsset id + asset_id?: string; + // asset = 从资产库/模特库/商品库挑的平台资产(模块4 · 4.1),后端按来源+审核态判能不能引用 + source?: "upload" | "library" | "library_group" | "asset"; group_id?: string; }; diff --git a/core/qa/visual-parity/_seg1-shots.mjs b/core/qa/visual-parity/_seg1-shots.mjs new file mode 100644 index 0000000..e79fdd5 --- /dev/null +++ b/core/qa/visual-parity/_seg1-shots.mjs @@ -0,0 +1,72 @@ +// 二期第1段验收抓图:脚本入口(两选) / 设定卡 / 上传脚本 / 三视图双通道 +import { chromium } from "playwright"; +import { mkdirSync } from "node:fs"; + +const BASE = process.env.BASE || "http://127.0.0.1:5173"; +const API = process.env.API || "http://127.0.0.1:8010"; +const OUT = process.argv[2] || "shots-seg1"; +mkdirSync(OUT, { recursive: true }); + +const login = await (await fetch(`${API}/api/auth/login/`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ username: "airshelf", password: "Restraint2026" }) +})).json(); +const tok = login.token; +const auth = { Authorization: `Token ${tok}`, "Content-Type": "application/json" }; + +// 要看的是「还没脚本」的空态,所以每次现建一个干净项目,抓完删掉 +const prods = (await (await fetch(`${API}/api/products/`, { headers: auth })).json()).results || []; +const target = await (await fetch(`${API}/api/projects/`, { + method: "POST", headers: auth, + body: JSON.stringify({ name: `二期验收抓图-${Date.now() % 100000}`, product: prods[0].id }) +})).json(); +console.log("project:", target.id, target.name); + +const browser = await chromium.launch(); +const ctx = await browser.newContext({ viewport: { width: 1440, height: 900 }, deviceScaleFactor: 2 }); +await ctx.addInitScript((t) => localStorage.setItem("airshelf_token", t), tok); +const page = await ctx.newPage(); +page.on("pageerror", (e) => console.error(" pageerror:", e.message)); + +async function shot(name, clip) { + await page.screenshot({ path: `${OUT}/${name}.png`, ...(clip ? { clip } : { fullPage: true }) }); + console.log("shot", name); +} + +// ── Stage 1 · 脚本入口 ── +await page.goto(`${BASE}/pipeline/${target.id}?st=1#stage-1`, { waitUntil: "networkidle", timeout: 40000 }); +await page.waitForTimeout(2000); +const pane = await page.locator(".chat-pane").first().boundingBox(); +await shot("1-script-entries", pane); + +// 点「脚本辅助生成」→ 设定卡 +await page.locator('.chat-mode[data-mode="ai"]').click(); +await page.waitForTimeout(700); +await shot("2-assist-setup", await page.locator(".chat-pane").first().boundingBox()); + +// 返回 → 点「上传脚本」(会拉起选文件,用 filechooser 拦掉并喂一个 txt) +await page.locator(".setup-foot .btn-ghost").first().click(); +await page.waitForTimeout(400); +page.once("filechooser", async (chooser) => { + await chooser.setFiles({ + name: "样稿.txt", + mimeType: "text/plain", + buffer: Buffer.from("钩子:还在为卡裆发愁?\n痛点:久坐一天就变形\n卖点:高弹面料不变形\nCTA:点小黄车", "utf-8") + }); +}); +await page.locator('.chat-mode[data-mode="manual"]').click(); +await page.waitForTimeout(1500); +await shot("3-upload-script", await page.locator(".chat-pane").first().boundingBox()); + +// ── Stage 2 · 三视图双通道 ── +// 新建项目还没脚本,Stage 2 会停在「提取角色/场景」门口,看不到商品卡 → 换已跑过的演示项目 +const projects = (await (await fetch(`${API}/api/projects/?page_size=100`, { headers: auth })).json()).results || []; +const demo = projects.find((p) => p.name.startsWith("演示")) || projects.find((p) => p.id !== target.id); +await page.goto(`${BASE}/pipeline/${demo.id}?st=2#stage-2`, { waitUntil: "networkidle", timeout: 40000 }); +await page.waitForTimeout(3000); +await shot("4-triview-channels", await page.locator(".prod-row").first().boundingBox()); + +await browser.close(); +await fetch(`${API}/api/projects/${target.id}/`, { method: "DELETE", headers: auth }); +console.log("DONE ->", OUT); diff --git a/core/qa/visual-parity/_seg2-e2e.mjs b/core/qa/visual-parity/_seg2-e2e.mjs new file mode 100644 index 0000000..c6cc6a6 --- /dev/null +++ b/core/qa/visual-parity/_seg2-e2e.mjs @@ -0,0 +1,57 @@ +// 二期第2段端到端:真实调脚本 agent,验「总时长 = 各镜相加」「单镜 4–15 秒」「组合回写」 +const API = process.env.API || "http://127.0.0.1:8010"; +const login = await (await fetch(`${API}/api/auth/login/`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ username: "airshelf", password: "Restraint2026" }) +})).json(); +const auth = { Authorization: `Token ${login.token}`, "Content-Type": "application/json" }; + +const prods = (await (await fetch(`${API}/api/products/`, { headers: auth })).json()).results || []; +const project = await (await fetch(`${API}/api/projects/`, { + method: "POST", headers: auth, + body: JSON.stringify({ name: `二期第2段e2e-${Date.now() % 100000}`, product: prods[0].id }) +})).json(); + +const CASES = [ + { total_duration: 40, presentation_format: "drama", video_structure: "pain" }, + { total_duration: 15, presentation_format: "oral", video_structure: "contrast" }, +]; + +for (const params of CASES) { + const res = await fetch(`${API}/api/projects/${project.id}/script-agent-stream/`, { + method: "POST", headers: auth, + body: JSON.stringify({ mode: "auto", aspect_ratio: "9:16", ...params }) + }); + let saved = null; + const reader = res.body.getReader(); + const dec = new TextDecoder(); + let buf = ""; + while (true) { + const { done, value } = await reader.read(); + if (done) break; + buf += dec.decode(value, { stream: true }); + // SSE 帧只有 data: 行,事件类型在 JSON 的 type 字段里 + for (const chunk of buf.split("\n\n").slice(0, -1)) { + const data = /^data:\s*([\s\S]+)$/m.exec(chunk)?.[1]; + if (!data) continue; + let ev; + try { ev = JSON.parse(data); } catch { continue; } + if (ev.type === "draft") saved = ev.draft; + if (ev.type === "error") console.error(" error:", data); + } + buf = buf.slice(buf.lastIndexOf("\n\n") + 2); + } + const draft = saved || {}; + const segs = draft.segments || []; + const durs = segs.map((s) => s.duration); + const sum = durs.reduce((a, b) => a + b, 0); + console.log( + `请求 ${params.total_duration}s ${params.presentation_format}×${params.video_structure}`, + "→ 出稿", draft?.total_duration, "s ·", segs.length, "镜 ·", JSON.stringify(durs), "加总", sum, + "|", draft?.presentation_format, draft?.video_structure, + sum === params.total_duration && durs.every((d) => d >= 4 && d <= 15) ? "✅" : "❌" + ); +} + +await fetch(`${API}/api/projects/${project.id}/`, { method: "DELETE", headers: auth }); diff --git a/core/qa/visual-parity/_seg2-shots.mjs b/core/qa/visual-parity/_seg2-shots.mjs new file mode 100644 index 0000000..ac80b18 --- /dev/null +++ b/core/qa/visual-parity/_seg2-shots.mjs @@ -0,0 +1,65 @@ +// 二期第2段验收抓图:设定卡「表现形式 × 视频结构 × 人物 × 时长」+ 组合联动 + 时长提醒 +import { chromium } from "playwright"; +import { mkdirSync } from "node:fs"; + +const BASE = process.env.BASE || "http://127.0.0.1:5173"; +const API = process.env.API || "http://127.0.0.1:8010"; +const OUT = process.argv[2] || "shots-seg2"; +mkdirSync(OUT, { recursive: true }); + +const login = await (await fetch(`${API}/api/auth/login/`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ username: "airshelf", password: "Restraint2026" }) +})).json(); +const tok = login.token; +const auth = { Authorization: `Token ${tok}`, "Content-Type": "application/json" }; + +// 设定卡只在「还没脚本」的空态出现,所以现建一个干净项目,抓完删掉 +const prods = (await (await fetch(`${API}/api/products/`, { headers: auth })).json()).results || []; +const target = await (await fetch(`${API}/api/projects/`, { + method: "POST", headers: auth, + body: JSON.stringify({ name: `二期第2段抓图-${Date.now() % 100000}`, product: prods[0].id }) +})).json(); +console.log("project:", target.id, "product:", prods[0].title, "/", prods[0].category); + +const browser = await chromium.launch(); +const ctx = await browser.newContext({ viewport: { width: 1440, height: 900 }, deviceScaleFactor: 2 }); +await ctx.addInitScript((t) => localStorage.setItem("airshelf_token", t), tok); +const page = await ctx.newPage(); +page.on("pageerror", (e) => console.error(" pageerror:", e.message)); + +const pane = () => page.locator(".chat-pane").first(); +async function shot(name) { + await page.screenshot({ path: `${OUT}/${name}.png`, clip: await pane().boundingBox() }); + console.log("shot", name); +} + +await page.goto(`${BASE}/pipeline/${target.id}?st=1#stage-1`, { waitUntil: "networkidle", timeout: 40000 }); +await page.waitForTimeout(2000); +await page.locator('.chat-mode[data-mode="ai"]').click(); +await page.waitForTimeout(700); +await shot("1-setup-default"); + +const selects = page.locator(".setup-card .setup-select"); +const structureOptions = async () => + selects.nth(1).locator("option").evaluateAll((els) => els.map((e) => e.textContent)); + +console.log("默认结构可选:", await structureOptions()); + +// 1.8 组合联动:选「短剧」后,视频结构里应当没有「测评验证」 +await selects.nth(0).selectOption("drama"); +await page.waitForTimeout(400); +console.log("短剧下结构可选:", await structureOptions()); +await shot("2-setup-drama-filtered"); + +// 时长调到最短 → 结构最短时长提醒(honey 色 .setup-rec.warn) +await selects.nth(0).selectOption("oral"); +await selects.nth(1).selectOption("review"); +await selects.nth(3).selectOption("5"); +await page.waitForTimeout(400); +await shot("3-setup-duration-warn"); + +await browser.close(); +await fetch(`${API}/api/projects/${target.id}/`, { method: "DELETE", headers: auth }); +console.log("DONE ->", OUT); diff --git a/core/qa/visual-parity/_seg5-e2e.mjs b/core/qa/visual-parity/_seg5-e2e.mjs new file mode 100644 index 0000000..8272f5d --- /dev/null +++ b/core/qa/visual-parity/_seg5-e2e.mjs @@ -0,0 +1,65 @@ +// 二期第5段端到端:上传视频 → 拆解稿 → 点确定 → 真出结构化脚本;并验「来源」记成「上传视频提炼」 +import { chromium } from "playwright"; +import { mkdirSync, existsSync } from "node:fs"; + +const BASE = process.env.BASE || "http://127.0.0.1:5173"; +const API = process.env.API || "http://127.0.0.1:8010"; +const OUT = process.argv[2] || "shots-seg5"; +const VIDEO = process.env.VIDEO || "/tmp/_probe_tshirt.mp4"; +mkdirSync(OUT, { recursive: true }); +if (!existsSync(VIDEO)) throw new Error(`测试视频不存在: ${VIDEO}`); + +const login = await (await fetch(`${API}/api/auth/login/`, { + method: "POST", headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ username: "airshelf", password: "Restraint2026" }) +})).json(); +const tok = login.token; +const auth = { Authorization: `Token ${tok}`, "Content-Type": "application/json" }; + +const prods = (await (await fetch(`${API}/api/products/`, { headers: auth })).json()).results || []; +const target = await (await fetch(`${API}/api/projects/`, { + method: "POST", headers: auth, + body: JSON.stringify({ name: `二期第5段E2E-${Date.now() % 100000}`, product: prods[0].id }) +})).json(); +console.log("project:", target.id, "| product:", prods[0].title); + +const browser = await chromium.launch(); +const ctx = await browser.newContext({ viewport: { width: 1440, height: 900 }, deviceScaleFactor: 2 }); +await ctx.addInitScript((t) => localStorage.setItem("airshelf_token", t), tok); +const page = await ctx.newPage(); +page.on("pageerror", (e) => console.error(" pageerror:", e.message)); + +await page.goto(`${BASE}/pipeline/${target.id}?st=1#stage-1`, { waitUntil: "networkidle", timeout: 40000 }); +await page.waitForTimeout(2000); + +const chooser = page.waitForEvent("filechooser"); +await page.locator('.chat-mode[data-mode="video"]').click(); +await (await chooser).setFiles(VIDEO); +await page.locator(".chat-attach-row .chip").first().waitFor({ timeout: 180000 }); +console.log("拆解稿字数:", (await page.locator("#chat-textarea").inputValue()).length); + +// 时长跟参考视频对齐(22 秒 → 20 秒档),再点确定生成 +await page.locator(".setup-card .setup-select").nth(3).selectOption("20"); +await page.locator(".setup-card .btn-primary").click(); +console.log("已点确定,等脚本生成…"); + +await page.locator(".shot-card, .shot-row").first().waitFor({ timeout: 240000 }); +await page.waitForTimeout(1500); + +const project = await (await fetch(`${API}/api/projects/${target.id}/`, { headers: auth })).json(); +const script = (project.script_versions || []).find((v) => v.is_adopted) || (project.script_versions || [])[0]; +console.log("脚本来源(后端记录):", script?.source); +console.log("镜数:", script?.segments?.length, "| 各镜时长:", (script?.segments || []).map((s) => s.duration_seconds).join("/")); +console.log("总时长:", (script?.segments || []).reduce((a, s) => a + s.duration_seconds, 0)); +console.log("第1镜旁白:", (script?.segments || [])[0]?.narration); +console.log("第1镜画面:", ((script?.segments || [])[0]?.visual_prompt || "").slice(0, 90)); + +const brief = await page.locator("#brief-source").textContent(); +console.log("来源徽标(前端):", brief); + +await page.screenshot({ path: `${OUT}/5-generated-script.png`, fullPage: false }); +console.log("shot 5-generated-script"); + +await browser.close(); +await fetch(`${API}/api/projects/${target.id}/`, { method: "DELETE", headers: auth }); +console.log("DONE"); diff --git a/core/qa/visual-parity/_seg5-shots.mjs b/core/qa/visual-parity/_seg5-shots.mjs new file mode 100644 index 0000000..04713ec --- /dev/null +++ b/core/qa/visual-parity/_seg5-shots.mjs @@ -0,0 +1,77 @@ +// 二期第5段验收:1.11 上传视频提炼 —— 三入口、真视频拆解、拆解稿可编辑、生成后来源徽标 +import { chromium } from "playwright"; +import { mkdirSync, existsSync } from "node:fs"; + +const BASE = process.env.BASE || "http://127.0.0.1:5173"; +const API = process.env.API || "http://127.0.0.1:8010"; +const OUT = process.argv[2] || "shots-seg5"; +const VIDEO = process.env.VIDEO || "/tmp/_probe_tshirt.mp4"; +mkdirSync(OUT, { recursive: true }); +if (!existsSync(VIDEO)) throw new Error(`测试视频不存在: ${VIDEO}`); + +const login = await (await fetch(`${API}/api/auth/login/`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ username: "airshelf", password: "Restraint2026" }) +})).json(); +const tok = login.token; +const auth = { Authorization: `Token ${tok}`, "Content-Type": "application/json" }; + +const prods = (await (await fetch(`${API}/api/products/`, { headers: auth })).json()).results || []; +const target = await (await fetch(`${API}/api/projects/`, { + method: "POST", headers: auth, + body: JSON.stringify({ name: `二期第5段抓图-${Date.now() % 100000}`, product: prods[0].id }) +})).json(); +console.log("project:", target.id, "product:", prods[0].title); + +const browser = await chromium.launch(); +const ctx = await browser.newContext({ viewport: { width: 1440, height: 900 }, deviceScaleFactor: 2 }); +await ctx.addInitScript((t) => localStorage.setItem("airshelf_token", t), tok); +const page = await ctx.newPage(); +page.on("pageerror", (e) => console.error(" pageerror:", e.message)); + +const pane = () => page.locator(".chat-pane").first(); +async function shot(name) { + await page.screenshot({ path: `${OUT}/${name}.png`, clip: await pane().boundingBox() }); + console.log("shot", name); +} + +await page.goto(`${BASE}/pipeline/${target.id}?st=1#stage-1`, { waitUntil: "networkidle", timeout: 40000 }); +await page.waitForTimeout(2000); + +// ① 空态三入口 +const modes = await page.locator(".chat-mode").allTextContents(); +console.log("入口:", modes); +if (modes.length !== 3) throw new Error(`应有三个入口,实际 ${modes.length}`); +await shot("1-three-entries"); + +// ② 选「上传视频提炼」→ 自动拉起选文件,喂真视频 +const chooser = page.waitForEvent("filechooser"); +await page.locator('.chat-mode[data-mode="video"]').click(); +await (await chooser).setFiles(VIDEO); +await page.waitForTimeout(800); +await shot("2-digesting"); + +// ③ 等拆解回来(抽帧 + 读帧,真调模型,给足 3 分钟) +await page.locator(".chat-attach-row .chip").first().waitFor({ timeout: 180000 }); +await page.waitForTimeout(600); +const digest = await page.locator("#chat-textarea").inputValue(); +console.log("拆解稿字数:", digest.length); +console.log("镜数:", (digest.match(/【第 \d+ 镜】/g) || []).length, "| 有存疑节:", digest.includes("【拆解存疑】")); +console.log("----- 前 260 字 -----\n" + digest.slice(0, 260)); +await shot("3-digest-in-editor"); + +// ④ 拆解稿必须可编辑 —— 这是 1.11 的硬要求(AI 拆解有已知毛病) +await page.locator("#chat-textarea").fill(digest + "\n【人工补充】第 1 镜改成室内暖光。"); +await page.waitForTimeout(300); +const edited = await page.locator("#chat-textarea").inputValue(); +console.log("可编辑:", edited.includes("【人工补充】")); +await shot("4-manually-edited"); + +// ⑤ 设定卡(表现形式/结构/人物/时长)照常出现,确定后进生成 +console.log("设定卡可见:", await page.locator(".setup-card").isVisible()); +console.log("设定卡引导语:", await page.locator(".setup-lead").textContent()); + +await browser.close(); +await fetch(`${API}/api/projects/${target.id}/`, { method: "DELETE", headers: auth }); +console.log("DONE ->", OUT); diff --git a/core/qa/visual-parity/_seg6-shots.mjs b/core/qa/visual-parity/_seg6-shots.mjs new file mode 100644 index 0000000..8b2fa3b --- /dev/null +++ b/core/qa/visual-parity/_seg6-shots.mjs @@ -0,0 +1,71 @@ +// 二期第6段验收抓图:自由创作「引用平台素材」弹窗(资产库 / 模特库 / 商品库三 tab) +import { chromium } from "playwright"; +import { mkdirSync } from "node:fs"; + +const BASE = process.env.BASE || "http://127.0.0.1:5173"; +const API = process.env.API || "http://127.0.0.1:8010"; +const OUT = process.argv[2] || "shots-seg6"; +mkdirSync(OUT, { recursive: true }); + +const login = await (await fetch(`${API}/api/auth/login/`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ username: "airshelf", password: "Restraint2026" }) +})).json(); +const tok = login.token; + +const browser = await chromium.launch(); +const ctx = await browser.newContext({ viewport: { width: 1440, height: 940 }, deviceScaleFactor: 2 }); +await ctx.addInitScript((t) => localStorage.setItem("airshelf_token", t), tok); +const page = await ctx.newPage(); +page.on("pageerror", (e) => console.error(" pageerror:", e.message)); + +async function shot(name, locator) { + const box = locator ? await locator.boundingBox() : null; + await page.screenshot({ path: `${OUT}/${name}.png`, ...(box ? { clip: box } : {}) }); + console.log("shot", name); +} + +await page.goto(`${BASE}/free-create`, { waitUntil: "networkidle", timeout: 40000 }); +await page.waitForTimeout(1500); +await shot("0-page"); + +await page.getByRole("button", { name: "引用平台素材", exact: true }).click(); +await page.waitForTimeout(1200); +const modal = page.locator(".fc-lib-modal"); +await shot("1-assets-tab", modal); + +const cards = await page.locator(".fc-lib-asset").count(); +const pickable = await page.locator(".fc-lib-asset.pickable").count(); +const pills = await page.locator(".fc-lib-status").allTextContents(); +console.log(`资产库:${cards} 张,可引用 ${pickable} 张,拦截徽标:`, [...new Set(pills)]); + +// 4.2 拦截态:上传来源且没审过的卡应当有「待送审」徽标 + 送审按钮,且不可点选 +const pending = page.locator(".fc-lib-asset", { has: page.locator(".fc-lib-status") }).first(); +if (await pending.count()) { + await pending.scrollIntoViewIfNeeded(); + await page.waitForTimeout(300); + await shot("1b-review-gate", pending); + console.log("拦截卡有送审按钮:", await pending.locator(".fc-lib-review-btn").count() > 0); + console.log("拦截卡可点选:", await pending.evaluate((el) => el.classList.contains("pickable"))); +} + +await page.locator(".fc-lib-tabs .tab", { hasText: "模特库" }).click(); +await page.waitForTimeout(1200); +await shot("2-models-tab", modal); +console.log("模特卡:", await page.locator(".fc-lib-asset").count()); + +await page.locator(".fc-lib-tabs .tab", { hasText: "商品库" }).click(); +await page.waitForTimeout(1200); +await shot("3-products-tab", modal); +const products = await page.locator(".fc-lib-asset").count(); +console.log("商品卡:", products); + +if (products > 0) { + await page.locator(".fc-lib-asset").first().click(); + await page.waitForTimeout(1500); + await shot("4-product-drilldown", modal); +} + +await browser.close(); +console.log("DONE ->", OUT); diff --git a/core/qa/visual-parity/shots-seg1/1-script-entries.png b/core/qa/visual-parity/shots-seg1/1-script-entries.png new file mode 100644 index 0000000..7f1ed31 Binary files /dev/null and b/core/qa/visual-parity/shots-seg1/1-script-entries.png differ diff --git a/core/qa/visual-parity/shots-seg1/2-assist-setup.png b/core/qa/visual-parity/shots-seg1/2-assist-setup.png new file mode 100644 index 0000000..b9feab1 Binary files /dev/null and b/core/qa/visual-parity/shots-seg1/2-assist-setup.png differ diff --git a/core/qa/visual-parity/shots-seg1/3-upload-script.png b/core/qa/visual-parity/shots-seg1/3-upload-script.png new file mode 100644 index 0000000..3c14b9d Binary files /dev/null and b/core/qa/visual-parity/shots-seg1/3-upload-script.png differ diff --git a/core/qa/visual-parity/shots-seg1/4-triview-channels.png b/core/qa/visual-parity/shots-seg1/4-triview-channels.png new file mode 100644 index 0000000..e006d65 Binary files /dev/null and b/core/qa/visual-parity/shots-seg1/4-triview-channels.png differ diff --git a/core/qa/visual-parity/shots-seg2/1-setup-default.png b/core/qa/visual-parity/shots-seg2/1-setup-default.png new file mode 100644 index 0000000..a0bc8b8 Binary files /dev/null and b/core/qa/visual-parity/shots-seg2/1-setup-default.png differ diff --git a/core/qa/visual-parity/shots-seg2/2-setup-drama-filtered.png b/core/qa/visual-parity/shots-seg2/2-setup-drama-filtered.png new file mode 100644 index 0000000..fbf4f1e Binary files /dev/null and b/core/qa/visual-parity/shots-seg2/2-setup-drama-filtered.png differ diff --git a/core/qa/visual-parity/shots-seg2/3-setup-duration-warn.png b/core/qa/visual-parity/shots-seg2/3-setup-duration-warn.png new file mode 100644 index 0000000..914d390 Binary files /dev/null and b/core/qa/visual-parity/shots-seg2/3-setup-duration-warn.png differ diff --git a/core/qa/visual-parity/shots-seg5/1-three-entries.png b/core/qa/visual-parity/shots-seg5/1-three-entries.png new file mode 100644 index 0000000..8982af7 Binary files /dev/null and b/core/qa/visual-parity/shots-seg5/1-three-entries.png differ diff --git a/core/qa/visual-parity/shots-seg5/2-digesting.png b/core/qa/visual-parity/shots-seg5/2-digesting.png new file mode 100644 index 0000000..195c1df Binary files /dev/null and b/core/qa/visual-parity/shots-seg5/2-digesting.png differ diff --git a/core/qa/visual-parity/shots-seg5/3-digest-in-editor.png b/core/qa/visual-parity/shots-seg5/3-digest-in-editor.png new file mode 100644 index 0000000..9739500 Binary files /dev/null and b/core/qa/visual-parity/shots-seg5/3-digest-in-editor.png differ diff --git a/core/qa/visual-parity/shots-seg5/4-manually-edited.png b/core/qa/visual-parity/shots-seg5/4-manually-edited.png new file mode 100644 index 0000000..ceb2685 Binary files /dev/null and b/core/qa/visual-parity/shots-seg5/4-manually-edited.png differ diff --git a/core/qa/visual-parity/shots-seg5/5-generated-script.png b/core/qa/visual-parity/shots-seg5/5-generated-script.png new file mode 100644 index 0000000..3f5afa1 Binary files /dev/null and b/core/qa/visual-parity/shots-seg5/5-generated-script.png differ diff --git a/core/qa/visual-parity/shots-seg6/0-page.png b/core/qa/visual-parity/shots-seg6/0-page.png new file mode 100644 index 0000000..4ea1e98 Binary files /dev/null and b/core/qa/visual-parity/shots-seg6/0-page.png differ diff --git a/core/qa/visual-parity/shots-seg6/1-assets-tab.png b/core/qa/visual-parity/shots-seg6/1-assets-tab.png new file mode 100644 index 0000000..5d904f3 Binary files /dev/null and b/core/qa/visual-parity/shots-seg6/1-assets-tab.png differ diff --git a/core/qa/visual-parity/shots-seg6/1b-review-gate.png b/core/qa/visual-parity/shots-seg6/1b-review-gate.png new file mode 100644 index 0000000..da6538f Binary files /dev/null and b/core/qa/visual-parity/shots-seg6/1b-review-gate.png differ diff --git a/core/qa/visual-parity/shots-seg6/2-models-tab.png b/core/qa/visual-parity/shots-seg6/2-models-tab.png new file mode 100644 index 0000000..c4f166e Binary files /dev/null and b/core/qa/visual-parity/shots-seg6/2-models-tab.png differ diff --git a/core/qa/visual-parity/shots-seg6/3-products-tab.png b/core/qa/visual-parity/shots-seg6/3-products-tab.png new file mode 100644 index 0000000..51340bb Binary files /dev/null and b/core/qa/visual-parity/shots-seg6/3-products-tab.png differ diff --git a/core/qa/visual-parity/shots-seg6/4-product-drilldown.png b/core/qa/visual-parity/shots-seg6/4-product-drilldown.png new file mode 100644 index 0000000..b59cea1 Binary files /dev/null and b/core/qa/visual-parity/shots-seg6/4-product-drilldown.png differ