大量修改二期功能清单内容
@@ -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。与《二期需求整理总纲》配套,随分期与对齐结论更新。*
|
||||||
@@ -99,6 +99,31 @@ def _refresh_processing_free_asset(free_asset: FreeAsset) -> bool:
|
|||||||
return False
|
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:
|
def build_content_items(*, team, prompt: str, mode: str, references: list) -> dict:
|
||||||
"""references → 火山 content_items + api_prompt(@label 已替换)。
|
"""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)
|
_remember_library_asset(fa)
|
||||||
continue
|
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 的直链)
|
# 直传素材(已上传 TOS 的直链)
|
||||||
if ref_type == "image":
|
if ref_type == "image":
|
||||||
# 参考图模式下所有图 role 必须 reference_image;keyframe 用 first_frame/last_frame
|
# 参考图模式下所有图 role 必须 reference_image;keyframe 用 first_frame/last_frame
|
||||||
|
|||||||
@@ -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),
|
||||||
|
),
|
||||||
|
]
|
||||||
@@ -92,6 +92,7 @@ class AITask(TeamOwnedModel):
|
|||||||
SCRIPT_GENERATION = "script_generation", "Script Generation"
|
SCRIPT_GENERATION = "script_generation", "Script Generation"
|
||||||
SCRIPT_OPTIMIZATION = "script_optimization", "Script Optimization"
|
SCRIPT_OPTIMIZATION = "script_optimization", "Script Optimization"
|
||||||
ENTITY_EXTRACTION = "entity_extraction", "Entity Extraction"
|
ENTITY_EXTRACTION = "entity_extraction", "Entity Extraction"
|
||||||
|
VIDEO_DIGEST = "video_digest", "Video Digest" # 上传视频提炼:参考视频 → 分镜稿
|
||||||
PRODUCT_IMAGE = "product_image", "Product Image"
|
PRODUCT_IMAGE = "product_image", "Product Image"
|
||||||
PERSON_IMAGE = "person_image", "Person Image"
|
PERSON_IMAGE = "person_image", "Person Image"
|
||||||
MODEL_TRIVIEW = "model_triview", "Model Triview"
|
MODEL_TRIVIEW = "model_triview", "Model Triview"
|
||||||
|
|||||||
@@ -19,6 +19,7 @@ SSE 事件(每帧 `data: {json}\n\n`,json 带 type):
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import json
|
import json
|
||||||
|
import math
|
||||||
import re
|
import re
|
||||||
from decimal import Decimal
|
from decimal import Decimal
|
||||||
from functools import lru_cache
|
from functools import lru_cache
|
||||||
@@ -34,7 +35,70 @@ from apps.billing.services.ledger import charge_reserved_credit, release_credit
|
|||||||
VALID_TONES = ["种草", "测评", "剧情", "痛点"]
|
VALID_TONES = ["种草", "测评", "剧情", "痛点"]
|
||||||
VALID_ROLES = ["钩子", "痛点", "卖点", "CTA"]
|
VALID_ROLES = ["钩子", "痛点", "卖点", "CTA"]
|
||||||
VALID_ENTITY_TYPES = ["character", "scene", "product"]
|
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"
|
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)
|
@lru_cache(maxsize=1)
|
||||||
def load_ecommerce_skill() -> str:
|
def _load_skill_base() -> str:
|
||||||
"""读取 SKILL.md + 全部 references 拼成系统提示词(领域知识)。缺文件不致命,尽量给。"""
|
"""SKILL.md + references 根目录下的通用资料(方法论/钩子库/品类/平台/自检),每次都要。
|
||||||
|
|
||||||
|
playbooks/ 是子目录,glob("*.md") 不会递归到,套路由 load_ecommerce_skill 按组合单独挑。
|
||||||
|
"""
|
||||||
skill_dir = _skill_dir()
|
skill_dir = _skill_dir()
|
||||||
parts: list[str] = []
|
parts: list[str] = []
|
||||||
main = skill_dir / "SKILL.md"
|
main = skill_dir / "SKILL.md"
|
||||||
@@ -64,11 +137,33 @@ def load_ecommerce_skill() -> str:
|
|||||||
ref_dir = skill_dir / "references"
|
ref_dir = skill_dir / "references"
|
||||||
if ref_dir.exists():
|
if ref_dir.exists():
|
||||||
for ref in sorted(ref_dir.glob("*.md")):
|
for ref in sorted(ref_dir.glob("*.md")):
|
||||||
parts.append(f"\n\n===== references/{ref.name} =====\n\n{ref.read_text(encoding='utf-8')}")
|
parts.append(_read_ref(ref, f"references/{ref.name}"))
|
||||||
if not parts:
|
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 目录)
|
# 兜底:skill 文件缺失也能退化生成(交接文档会提示补 skills 目录)
|
||||||
return "你是电商带货短视频脚本生成 agent,输出结构化 ScriptDraft JSON。"
|
return "你是电商带货短视频脚本生成 agent,输出结构化 ScriptDraft JSON。"
|
||||||
return "".join(parts)
|
return joined
|
||||||
|
|
||||||
|
|
||||||
# 运行时输出协议:优先级高于 skill 里的「只输出 JSON / 不展示思考」,只为流式体感放开一句前言。
|
# 运行时输出协议:优先级高于 skill 里的「只输出 JSON / 不展示思考」,只为流式体感放开一句前言。
|
||||||
@@ -119,12 +214,25 @@ def build_agent_messages(
|
|||||||
base_draft: dict | None,
|
base_draft: dict | None,
|
||||||
aspect_ratio: str,
|
aspect_ratio: str,
|
||||||
total_duration: int,
|
total_duration: int,
|
||||||
|
presentation_format: str = DEFAULT_PRESENTATION_FORMAT,
|
||||||
|
video_structure: str = DEFAULT_VIDEO_STRUCTURE,
|
||||||
target_index: int | None = None,
|
target_index: int | None = None,
|
||||||
) -> list[dict[str, str]]:
|
) -> 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 = (
|
head = (
|
||||||
f"【画幅】{aspect_ratio}\n"
|
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)}"
|
f"【商品信息】\n{_product_context(project, selling_point_ids)}"
|
||||||
)
|
)
|
||||||
if mode == "revise" and base_draft and target_index is not None:
|
if mode == "revise" and base_draft and target_index is not None:
|
||||||
@@ -154,7 +262,8 @@ def build_agent_messages(
|
|||||||
)
|
)
|
||||||
else:
|
else:
|
||||||
user = (
|
user = (
|
||||||
"【任务】全自动(模式①):仅凭商品与前置条件,自动定档/选 tone/造 entity/填黄金结构。\n"
|
"【任务】全自动(模式①):仅凭商品与前置条件,按指定的表现形式与视频结构套路"
|
||||||
|
"自动定镜数/选 tone/造 entity/填结构骨架。\n"
|
||||||
f"{head}\n\n"
|
f"{head}\n\n"
|
||||||
"请按技能流程一次性产出 ScriptDraft。"
|
"请按技能流程一次性产出 ScriptDraft。"
|
||||||
)
|
)
|
||||||
@@ -217,14 +326,87 @@ def _extract_json(text: str) -> str | None:
|
|||||||
return _balanced_object(text)
|
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:
|
try:
|
||||||
value = int(value)
|
value = int(value)
|
||||||
except (TypeError, ValueError):
|
except (TypeError, ValueError):
|
||||||
return 60
|
return DEFAULT_TOTAL_DURATION
|
||||||
if value in DURATION_TIERS:
|
if value <= 0:
|
||||||
return value
|
return DEFAULT_TOTAL_DURATION
|
||||||
return min(DURATION_TIERS, key=lambda t: abs(t - value))
|
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…),
|
# 模型每次生成都可能换字段名(scene/screenDescription/visual…、dialogue/lines/caption…),
|
||||||
@@ -306,7 +488,14 @@ def _resolve_segments(draft: dict) -> list:
|
|||||||
return best
|
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契约规范化。宽容:小问题就地修,不轻易抛错。"""
|
"""把模型输出抽成 JSON 并按铁律1契约规范化。宽容:小问题就地修,不轻易抛错。"""
|
||||||
blob = _extract_json(raw_text)
|
blob = _extract_json(raw_text)
|
||||||
if not blob:
|
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["segments"] = _resolve_segments(draft)
|
||||||
|
|
||||||
draft["aspect_ratio"] = (draft.get("aspect_ratio") or aspect_ratio or "9:16").strip()
|
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
|
draft["total_duration"] = dur
|
||||||
seg_count = max(1, dur // 15)
|
fmt, structure = coerce_combo(presentation_format, video_structure)
|
||||||
draft["segment_count"] = seg_count
|
draft["presentation_format"] = PRESENTATION_FORMATS[fmt]
|
||||||
|
draft["video_structure"] = VIDEO_STRUCTURES[structure]
|
||||||
tone = (draft.get("tone") or "").strip()
|
tone = (draft.get("tone") or "").strip()
|
||||||
draft["tone"] = tone if tone in VALID_TONES else "种草"
|
draft["tone"] = tone if tone in VALID_TONES else "种草"
|
||||||
draft["hook"] = (draft.get("hook") or "").strip()
|
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
|
draft["entities"] = norm_entities
|
||||||
valid_ids = {e["id"] for e in 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 []
|
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] = []
|
norm_segments: list[dict] = []
|
||||||
for i, seg in enumerate(segments[:seg_count]):
|
for i, seg in enumerate(segments):
|
||||||
if not isinstance(seg, dict):
|
if not isinstance(seg, dict):
|
||||||
seg = {}
|
seg = {}
|
||||||
role = (seg.get("role") or "").strip()
|
role = (seg.get("role") or "").strip()
|
||||||
if role not in VALID_ROLES:
|
if role not in VALID_ROLES:
|
||||||
role = VALID_ROLES[min(i, len(VALID_ROLES) - 1)]
|
role = role_plan[i]
|
||||||
speaker = seg.get("speaker")
|
speaker = seg.get("speaker")
|
||||||
speaker = speaker if (speaker in valid_ids) else None
|
speaker = speaker if (speaker in valid_ids) else None
|
||||||
refs = [r for r in (seg.get("entity_refs") or []) if r in valid_ids]
|
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(
|
norm_segments.append(
|
||||||
{
|
{
|
||||||
"index": i,
|
"index": i,
|
||||||
"duration": 15,
|
"duration": seg.get("duration"), # 先原样收着,等镜数定了再统一夹进 4–15 秒并配平总时长
|
||||||
"role": role,
|
"role": role,
|
||||||
"narration": narration,
|
"narration": narration,
|
||||||
"speaker": speaker,
|
"speaker": speaker,
|
||||||
@@ -416,14 +613,14 @@ def normalize_draft(raw_text: str, *, aspect_ratio: str, total_duration: int) ->
|
|||||||
"dialogue": dialogue,
|
"dialogue": dialogue,
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
# 不足镜数则补占位镜(极少发生,避免下游镜数对不上)
|
# 不足下限则补占位镜(极少发生,避免出现超过 15 秒的镜导致下游拒片)
|
||||||
while len(norm_segments) < seg_count:
|
while len(norm_segments) < seg_count:
|
||||||
i = len(norm_segments)
|
i = len(norm_segments)
|
||||||
norm_segments.append(
|
norm_segments.append(
|
||||||
{
|
{
|
||||||
"index": i,
|
"index": i,
|
||||||
"duration": 15,
|
"duration": None,
|
||||||
"role": VALID_ROLES[min(i, len(VALID_ROLES) - 1)],
|
"role": role_plan[i],
|
||||||
"narration": "",
|
"narration": "",
|
||||||
"speaker": None,
|
"speaker": None,
|
||||||
"visual": "",
|
"visual": "",
|
||||||
@@ -434,11 +631,26 @@ def normalize_draft(raw_text: str, *, aspect_ratio: str, total_duration: int) ->
|
|||||||
)
|
)
|
||||||
if not norm_segments:
|
if not norm_segments:
|
||||||
raise ValueError("脚本没有任何分镜")
|
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["segments"] = norm_segments
|
||||||
|
draft["segment_count"] = len(norm_segments)
|
||||||
return draft
|
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(对白可能加角色)。再整体规范化。"""
|
"""精准改一镜:以基准稿为底,只用新稿的第 idx 镜替换,其余镜逐字保持;合并新稿引入的新 entity(对白可能加角色)。再整体规范化。"""
|
||||||
merged = json.loads(json.dumps(base)) # 深拷贝
|
merged = json.loads(json.dumps(base)) # 深拷贝
|
||||||
base_ids = {e.get("id") for e in merged.get("entities", []) if isinstance(e, dict)}
|
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
|
target["index"] = idx
|
||||||
segs[idx] = target
|
segs[idx] = target
|
||||||
merged["segments"] = segs
|
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,
|
task=task,
|
||||||
title=(draft.get("hook") or "AI 脚本")[:128],
|
title=(draft.get("hook") or "AI 脚本")[:128],
|
||||||
content=json.dumps(draft, ensure_ascii=False, indent=2),
|
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,
|
is_adopted=False,
|
||||||
metadata={
|
metadata={
|
||||||
"hook": draft.get("hook", ""),
|
"hook": draft.get("hook", ""),
|
||||||
"tone": draft.get("tone", ""),
|
"tone": draft.get("tone", ""),
|
||||||
"aspect_ratio": draft.get("aspect_ratio", "9:16"),
|
"aspect_ratio": draft.get("aspect_ratio", "9:16"),
|
||||||
"total_duration": draft.get("total_duration", 60),
|
"total_duration": draft.get("total_duration", DEFAULT_TOTAL_DURATION),
|
||||||
"segment_count": draft.get("segment_count", 4),
|
"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", []),
|
"entities": draft.get("entities", []),
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
@@ -511,7 +732,7 @@ def persist_script_draft(*, project, user, task, draft: dict, source: str):
|
|||||||
ScriptSegment.objects.create(
|
ScriptSegment.objects.create(
|
||||||
script_version=script,
|
script_version=script,
|
||||||
sort_order=seg["index"],
|
sort_order=seg["index"],
|
||||||
duration_seconds=seg.get("duration", 15),
|
duration_seconds=seg.get("duration") or SEGMENT_DURATION_MAX,
|
||||||
narration=seg.get("narration", ""),
|
narration=seg.get("narration", ""),
|
||||||
visual_prompt=seg.get("visual", ""),
|
visual_prompt=seg.get("visual", ""),
|
||||||
role=seg.get("role", ""),
|
role=seg.get("role", ""),
|
||||||
@@ -570,15 +791,20 @@ def stream_script_agent(
|
|||||||
selling_point_ids: list[str] | None = None,
|
selling_point_ids: list[str] | None = None,
|
||||||
base_version_id: str | None = None,
|
base_version_id: str | None = None,
|
||||||
aspect_ratio: str = "9:16",
|
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,
|
target_index: int | None = None,
|
||||||
|
entry_source: str = "",
|
||||||
):
|
):
|
||||||
"""生成 SSE 帧字符串的同步生成器,供 StreamingHttpResponse 包裹。
|
"""生成 SSE 帧字符串的同步生成器,供 StreamingHttpResponse 包裹。
|
||||||
target_index 非空 = 精准只改第 N 镜(读全脚本上下文,后端强制保留其余镜原样)。"""
|
target_index 非空 = 精准只改第 N 镜(读全脚本上下文,后端强制保留其余镜原样)。"""
|
||||||
from apps.ai.services import create_ai_task, stream_routed_text_request
|
from apps.ai.services import create_ai_task, stream_routed_text_request
|
||||||
|
|
||||||
yield _sse({"type": "tool", "id": "skill", "label": "加载电商脚本技能", "status": "running"})
|
fmt, structure = coerce_combo(presentation_format, video_structure)
|
||||||
skill_loaded = bool(load_ecommerce_skill())
|
|
||||||
|
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": "skill", "status": "done" if skill_loaded else "error"})
|
||||||
|
|
||||||
yield _sse({"type": "tool", "id": "analyze", "label": f"分析商品:{project.product.title}", "status": "running"})
|
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)
|
base_draft = _load_base_draft(project, base_version_id)
|
||||||
if base_draft is None:
|
if base_draft is None:
|
||||||
target_index = 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)
|
# 精准改一镜:镜号越界直接报错返回,绝不建任务/扣费(避免计费空转的静默 no-op)
|
||||||
if target_index is not None and base_draft is not None:
|
if target_index is not None and base_draft is not None:
|
||||||
seg_n = len(base_draft.get("segments", []))
|
seg_n = len(base_draft.get("segments", []))
|
||||||
@@ -602,7 +830,9 @@ def stream_script_agent(
|
|||||||
selling_point_ids=selling_point_ids,
|
selling_point_ids=selling_point_ids,
|
||||||
base_draft=base_draft,
|
base_draft=base_draft,
|
||||||
aspect_ratio=aspect_ratio,
|
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,
|
target_index=target_index,
|
||||||
)
|
)
|
||||||
yield _sse({"type": "tool", "id": "analyze", "status": "done"})
|
yield _sse({"type": "tool", "id": "analyze", "status": "done"})
|
||||||
@@ -648,6 +878,8 @@ def stream_script_agent(
|
|||||||
raw_text,
|
raw_text,
|
||||||
aspect_ratio=aspect_ratio,
|
aspect_ratio=aspect_ratio,
|
||||||
total_duration=effective_duration,
|
total_duration=effective_duration,
|
||||||
|
presentation_format=fmt,
|
||||||
|
video_structure=structure,
|
||||||
)
|
)
|
||||||
if target_index is not None and base_draft:
|
if target_index is not None and base_draft:
|
||||||
return _merge_single_segment(
|
return _merge_single_segment(
|
||||||
@@ -656,6 +888,8 @@ def stream_script_agent(
|
|||||||
target_index,
|
target_index,
|
||||||
aspect_ratio,
|
aspect_ratio,
|
||||||
effective_duration,
|
effective_duration,
|
||||||
|
fmt,
|
||||||
|
structure,
|
||||||
)
|
)
|
||||||
return candidate
|
return candidate
|
||||||
|
|
||||||
@@ -735,7 +969,14 @@ def stream_script_agent(
|
|||||||
task.completed_at = timezone.now()
|
task.completed_at = timezone.now()
|
||||||
task.save(update_fields=["status", "response_payload", "actual_cost", "completed_at", "updated_at"])
|
task.save(update_fields=["status", "response_payload", "actual_cost", "completed_at", "updated_at"])
|
||||||
charge_reserved_credit(reservation=reservation, actual_amount=task.actual_cost)
|
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)
|
script = persist_script_draft(project=project, user=user, task=task, draft=draft, source=source)
|
||||||
settled = True # charge 已提交
|
settled = True # charge 已提交
|
||||||
except Exception as exc: # noqa: BLE001 — 落库失败:atomic 已回滚 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:
|
def _draft_from_version(version) -> dict:
|
||||||
"""从 ScriptVersion 的 DB 行(segments + metadata)重建 ScriptDraft —— 比解析可能已 stale 的 content 可靠
|
"""从 ScriptVersion 的 DB 行(segments + metadata)重建 ScriptDraft —— 比解析可能已 stale 的 content 可靠
|
||||||
(用户增删/改镜后 content 不一定同步)。total_duration 按真实镜数算,避免 normalize 按 stale 值截/补镜。"""
|
(用户增删/改镜后 content 不一定同步)。total_duration 按各镜真实秒数加总,避免 normalize 按 stale 值截/补镜。"""
|
||||||
meta = version.metadata or {}
|
meta = version.metadata or {}
|
||||||
segs = list(version.segments.order_by("sort_order"))
|
segs = list(version.segments.order_by("sort_order"))
|
||||||
|
# 镜可以不等长了,总时长必须按实际相加(旧写法 15×镜数 会在不等长稿上算出错误总时长)
|
||||||
|
actual_total = sum(s.duration_seconds or SEGMENT_DURATION_MAX for s in segs)
|
||||||
return {
|
return {
|
||||||
"hook": meta.get("hook", ""),
|
"hook": meta.get("hook", ""),
|
||||||
"tone": meta.get("tone", ""),
|
"tone": meta.get("tone", ""),
|
||||||
|
"presentation_format": meta.get("presentation_format", ""),
|
||||||
|
"video_structure": meta.get("video_structure", ""),
|
||||||
"aspect_ratio": meta.get("aspect_ratio", "9:16"),
|
"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),
|
"segment_count": len(segs),
|
||||||
"entities": meta.get("entities", []),
|
"entities": meta.get("entities", []),
|
||||||
"segments": [
|
"segments": [
|
||||||
{
|
{
|
||||||
"index": s.sort_order,
|
"index": s.sort_order,
|
||||||
"duration": s.duration_seconds or 15,
|
"duration": s.duration_seconds or SEGMENT_DURATION_MAX,
|
||||||
"role": s.role or "",
|
"role": s.role or "",
|
||||||
"narration": s.narration or "",
|
"narration": s.narration or "",
|
||||||
"speaker": s.speaker or None,
|
"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 [])
|
seg_n = len(base_draft.get("segments") or [])
|
||||||
if not (0 <= target_index < seg_n):
|
if not (0 <= target_index < seg_n):
|
||||||
raise ValueError(f"镜号越界:第 {target_index + 1} 镜(共 {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(
|
messages = build_agent_messages(
|
||||||
project=project,
|
project=project,
|
||||||
@@ -870,6 +1117,8 @@ def regenerate_segment_via_agent(*, project, user, model_config: ModelConfig, se
|
|||||||
base_draft=base_draft,
|
base_draft=base_draft,
|
||||||
aspect_ratio=aspect_ratio,
|
aspect_ratio=aspect_ratio,
|
||||||
total_duration=total_duration,
|
total_duration=total_duration,
|
||||||
|
presentation_format=fmt,
|
||||||
|
video_structure=structure,
|
||||||
target_index=target_index,
|
target_index=target_index,
|
||||||
)
|
)
|
||||||
task = create_ai_task(
|
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.submitted_at = timezone.now()
|
||||||
task.save(update_fields=["status", "submitted_at", "updated_at"])
|
task.save(update_fields=["status", "submitted_at", "updated_at"])
|
||||||
def validate_segment_text(raw_text: str) -> dict:
|
def validate_segment_text(raw_text: str) -> dict:
|
||||||
candidate = normalize_draft(raw_text, aspect_ratio=aspect_ratio, total_duration=total_duration)
|
candidate = normalize_draft(
|
||||||
return _merge_single_segment(base_draft, candidate, target_index, aspect_ratio, total_duration)
|
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(
|
routed = execute_routed_text_request(
|
||||||
task=task,
|
task=task,
|
||||||
|
|||||||
@@ -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))
|
||||||
@@ -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)
|
||||||
@@ -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")
|
||||||
@@ -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)
|
||||||
@@ -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
|
||||||
@@ -59,11 +59,16 @@ def get_or_create_team_group(team) -> AssetReviewGroup:
|
|||||||
return grp
|
return grp
|
||||||
|
|
||||||
|
|
||||||
def submit_asset_for_review(asset: Asset) -> bool:
|
def submit_asset_for_review(asset: Asset, *, force: bool = False) -> bool:
|
||||||
"""真人资产送审:建组(若无)→ 传素材 → 标 processing。出错只记日志,不抛。
|
"""真人资产送审:建组(若无)→ 传素材 → 标 processing。出错只记日志,不抛。
|
||||||
返回是否真正进入审核(True=已标 processing;False=未送审/未配置/失败),
|
返回是否真正进入审核(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
|
return False
|
||||||
url = _asset_url(asset)
|
url = _asset_url(asset)
|
||||||
if not url:
|
if not url:
|
||||||
@@ -85,6 +90,32 @@ def submit_asset_for_review(asset: Asset) -> bool:
|
|||||||
return False
|
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:
|
def poll_asset_review(asset: Asset) -> str:
|
||||||
"""查单个真人资产审核状态并更新 review_status。返回最新状态。
|
"""查单个真人资产审核状态并更新 review_status。返回最新状态。
|
||||||
只在状态变化时落库(保留 updated_at 作为「进入 processing 的时刻」);processing 超时兜底为 failed。"""
|
只在状态变化时落库(保留 updated_at 作为「进入 processing 的时刻」);processing 超时兜底为 failed。"""
|
||||||
|
|||||||
@@ -853,7 +853,7 @@ class SubmitReviewTests(TestCase):
|
|||||||
|
|
||||||
def test_submit_review_200_when_started(self):
|
def test_submit_review_200_when_started(self):
|
||||||
"""真正进入审核(processing)→ 200 + 真态,供前端落 processing。"""
|
"""真正进入审核(processing)→ 200 + 真态,供前端落 processing。"""
|
||||||
def _mark_processing(asset):
|
def _mark_processing(asset, *, force=False):
|
||||||
asset.review_status = "processing"
|
asset.review_status = "processing"
|
||||||
asset.save(update_fields=["review_status"])
|
asset.save(update_fields=["review_status"])
|
||||||
return True
|
return True
|
||||||
|
|||||||
@@ -265,13 +265,18 @@ class AssetViewSet(TeamScopedViewSetMixin, ModelViewSet):
|
|||||||
@action(detail=True, methods=["post"], url_path="submit-review")
|
@action(detail=True, methods=["post"], url_path="submit-review")
|
||||||
def submit_review(self, request, pk=None):
|
def submit_review(self, request, pk=None):
|
||||||
"""手动兜底:把单个送审类素材(角色/三视图/分镜)送火山审核 —— 用户点灰盾「待审核」时触发。
|
"""手动兜底:把单个送审类素材(角色/三视图/分镜)送火山审核 —— 用户点灰盾「待审核」时触发。
|
||||||
正常走 on_commit 自动送审;此口给万一漏提交时补提交。非送审类拒绝。"""
|
正常走 on_commit 自动送审;此口给万一漏提交时补提交。非送审类拒绝。
|
||||||
|
|
||||||
|
for_reference=true:素材要被拿去当生成参考(自由创作 @引用三库)。此时不再看类目白名单——
|
||||||
|
白名单只是「别为无关素材白跑一趟审核」的优化,而参考素材里有没有真人脸我们无从判断,
|
||||||
|
一律登记一次。放宽的是送审范围,不是放行范围,不会削弱审核。"""
|
||||||
from apps.assets.review import submit_asset_for_review
|
from apps.assets.review import submit_asset_for_review
|
||||||
|
|
||||||
asset = self.get_object() # team-scoped + is_deleted 过滤
|
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)
|
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()
|
asset.refresh_from_db()
|
||||||
# 没真正送出去(审核服务未启用/未配置 SDK/火山未回 Id/调用异常),且素材也不是已终态:
|
# 没真正送出去(审核服务未启用/未配置 SDK/火山未回 Id/调用异常),且素材也不是已终态:
|
||||||
# 如实回报 503,别返回空 review_status —— 否则前端会把「没送出去」误显示成「审核中」,刷新就打回原形。
|
# 如实回报 503,别返回空 review_status —— 否则前端会把「没送出去」误显示成「审核中」,刷新就打回原形。
|
||||||
|
|||||||
@@ -0,0 +1,77 @@
|
|||||||
|
"""上传脚本取正文 —— 只认 docx / txt。
|
||||||
|
|
||||||
|
docx 用标准库拆(zip + xml),不引 python-docx:镜像少一个依赖,也避免它对损坏文件抛一堆内部异常。
|
||||||
|
段落 = <w:p>,文字 = 其中所有 <w:t> 拼接;<w:br>/<w:tab> 补空白,否则整段会粘成一坨。
|
||||||
|
"""
|
||||||
|
|
||||||
|
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
|
||||||
@@ -15,7 +15,13 @@ from rest_framework.viewsets import ModelViewSet
|
|||||||
|
|
||||||
from apps.ai.models import AITask, ModelConfig
|
from apps.ai.models import AITask, ModelConfig
|
||||||
from apps.ai.providers import TtsNotConfigured
|
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 (
|
from apps.ai.services import (
|
||||||
DEFAULT_VOICEOVER_VOICE,
|
DEFAULT_VOICEOVER_VOICE,
|
||||||
VOICEOVER_VOICES,
|
VOICEOVER_VOICES,
|
||||||
@@ -39,6 +45,7 @@ from apps.assets.storage import TosStorage
|
|||||||
from apps.common.api import TeamScopedViewSetMixin
|
from apps.common.api import TeamScopedViewSetMixin
|
||||||
from apps.common.celery_health import require_worker
|
from apps.common.celery_health import require_worker
|
||||||
from apps.ai.generation_errors import classify_generation_error, public_error_for_task
|
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 (
|
from .models import (
|
||||||
BaseAssetGroup,
|
BaseAssetGroup,
|
||||||
@@ -69,6 +76,7 @@ from .serializers import (
|
|||||||
)
|
)
|
||||||
from .services.export import run_export_job_in_thread
|
from .services.export import run_export_job_in_thread
|
||||||
from .services.pipeline import STAGE_ORDER
|
from .services.pipeline import STAGE_ORDER
|
||||||
|
from .services.script_import import ScriptFileError, extract_script_text
|
||||||
from .tasks import poll_video_segment_task
|
from .tasks import poll_video_segment_task
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
@@ -417,14 +425,18 @@ class ProjectViewSet(TeamScopedViewSetMixin, ModelViewSet):
|
|||||||
project = serializer.save(team=self.get_team(), created_by=self.request.user)
|
project = serializer.save(team=self.get_team(), created_by=self.request.user)
|
||||||
for stage in STAGE_ORDER:
|
for stage in STAGE_ORDER:
|
||||||
ProjectStage.objects.create(project=project, stage=stage)
|
ProjectStage.objects.create(project=project, stage=stage)
|
||||||
|
# 先铺 4 段占位;真实段数与每段时长在采用脚本时由 _sync_video_segments_to_script 收口
|
||||||
for index in range(4):
|
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])
|
@action(detail=True, methods=["post"], url_path="script-agent-stream", renderer_classes=[ServerSentEventRenderer])
|
||||||
def script_agent_stream(self, request, pk=None):
|
def script_agent_stream(self, request, pk=None):
|
||||||
"""对话式脚本 agent · 流式(SSE)。出稿 + 改稿一体,多模型可选。
|
"""对话式脚本 agent · 流式(SSE)。出稿 + 改稿一体,多模型可选。
|
||||||
请求体:mode(auto|theme|revise)、prompt、model_config_id、selling_point_ids、
|
请求体: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。"""
|
响应:text/event-stream,逐帧吐 tool/delta/draft/saved/done/error。"""
|
||||||
project = self.get_object()
|
project = self.get_object()
|
||||||
mode = str(request.data.get("mode") or "auto")
|
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 []
|
selling_point_ids = request.data.get("selling_point_ids") or []
|
||||||
base_version_id = request.data.get("base_version_id") or None
|
base_version_id = request.data.get("base_version_id") or None
|
||||||
aspect_ratio = str(request.data.get("aspect_ratio") or "9:16")
|
aspect_ratio = str(request.data.get("aspect_ratio") or "9:16")
|
||||||
try:
|
# 非法值一律由 agent 侧 coerce 兜底(夹区间/回落默认),这里不做 400,避免生成被参数噪声打断
|
||||||
total_duration = int(request.data.get("total_duration") or 60)
|
total_duration = coerce_total_duration(request.data.get("total_duration"))
|
||||||
except (TypeError, ValueError):
|
presentation_format, video_structure = coerce_combo(
|
||||||
total_duration = 60
|
request.data.get("presentation_format"),
|
||||||
|
request.data.get("video_structure"),
|
||||||
|
)
|
||||||
target_index = request.data.get("target_index")
|
target_index = request.data.get("target_index")
|
||||||
try:
|
try:
|
||||||
target_index = int(target_index) if target_index is not None else None
|
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,
|
base_version_id=base_version_id,
|
||||||
aspect_ratio=aspect_ratio,
|
aspect_ratio=aspect_ratio,
|
||||||
total_duration=total_duration,
|
total_duration=total_duration,
|
||||||
|
presentation_format=presentation_format,
|
||||||
|
video_structure=video_structure,
|
||||||
target_index=target_index,
|
target_index=target_index,
|
||||||
|
entry_source=str(request.data.get("source") or ""),
|
||||||
)
|
)
|
||||||
response = StreamingHttpResponse(stream, content_type="text/event-stream")
|
response = StreamingHttpResponse(stream, content_type="text/event-stream")
|
||||||
response["Cache-Control"] = "no-cache"
|
response["Cache-Control"] = "no-cache"
|
||||||
@@ -790,11 +807,16 @@ class ProjectViewSet(TeamScopedViewSetMixin, ModelViewSet):
|
|||||||
# ── Stage 1 · 镜头脚本逐字段编辑 / 增删分镜 ──
|
# ── Stage 1 · 镜头脚本逐字段编辑 / 增删分镜 ──
|
||||||
|
|
||||||
def _sync_video_segments_to_script(self, project: Project, script: ScriptVersion) -> None:
|
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:
|
if not script.is_adopted:
|
||||||
return
|
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"))
|
segments = list(project.video_segments.order_by("sort_order"))
|
||||||
while len(segments) > target:
|
while len(segments) > target:
|
||||||
tail = segments[-1]
|
tail = segments[-1]
|
||||||
@@ -805,9 +827,34 @@ class ProjectViewSet(TeamScopedViewSetMixin, ModelViewSet):
|
|||||||
break
|
break
|
||||||
next_order = (segments[-1].sort_order + 1) if segments else 0
|
next_order = (segments[-1].sort_order + 1) if segments else 0
|
||||||
for _ in range(target - len(segments)):
|
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
|
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:
|
def _sync_storyboard_shots_to_script(self, project: Project, script: ScriptVersion) -> None:
|
||||||
"""采用版分镜数变化时,同步 StoryboardShot 数量(与视频段同策略,按位置对齐):
|
"""采用版分镜数变化时,同步 StoryboardShot 数量(与视频段同策略,按位置对齐):
|
||||||
多出且尾部「从未出过图」的 shot 裁掉(已出图的不动);不主动建——出图时 ensure_storyboard_shots 按需补。"""
|
多出且尾部「从未出过图」的 shot 裁掉(已出图的不动);不主动建——出图时 ensure_storyboard_shots 按需补。"""
|
||||||
@@ -834,7 +881,10 @@ class ProjectViewSet(TeamScopedViewSetMixin, ModelViewSet):
|
|||||||
changed.append(field)
|
changed.append(field)
|
||||||
if "duration_seconds" in request.data:
|
if "duration_seconds" in request.data:
|
||||||
try:
|
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")
|
changed.append("duration_seconds")
|
||||||
except (TypeError, ValueError):
|
except (TypeError, ValueError):
|
||||||
pass
|
pass
|
||||||
@@ -877,7 +927,10 @@ class ProjectViewSet(TeamScopedViewSetMixin, ModelViewSet):
|
|||||||
created = ScriptSegment.objects.create(
|
created = ScriptSegment.objects.create(
|
||||||
script_version=script,
|
script_version=script,
|
||||||
sort_order=insert_at,
|
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(),
|
narration=str(request.data.get("narration") or "").strip(),
|
||||||
visual_prompt=str(request.data.get("visual_prompt") 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"])
|
segment.save(update_fields=["adopted_version", "status", "error_message", "updated_at"])
|
||||||
return Response(ProjectSerializer(project).data, status=status.HTTP_201_CREATED)
|
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])
|
@action(detail=True, methods=["post"], url_path="upload-bgm", parser_classes=[MultiPartParser, FormParser])
|
||||||
@transaction.atomic
|
@transaction.atomic
|
||||||
def upload_bgm_action(self, request, pk=None):
|
def upload_bgm_action(self, request, pk=None):
|
||||||
|
|||||||
@@ -4,7 +4,9 @@ description: >
|
|||||||
电商带货短视频·脚本生成领域技能(模型无关)。
|
电商带货短视频·脚本生成领域技能(模型无关)。
|
||||||
服务对象不是人类编剧,而是 AirShelf 产品后端的「脚本生成 agent」——在运行时按需加载本技能作为领域知识。
|
服务对象不是人类编剧,而是 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秒主打钩子(一句话)",
|
"hook": "前3秒主打钩子(一句话)",
|
||||||
"tone": "种草|测评|剧情|痛点",
|
"tone": "种草|测评|剧情|痛点",
|
||||||
|
"presentation_format": "口播|短剧|Vlog",
|
||||||
|
"video_structure": "痛点解决|前后对比|测评验证|场景种草",
|
||||||
"aspect_ratio": "9:16",
|
"aspect_ratio": "9:16",
|
||||||
"total_duration": 60,
|
"total_duration": 30,
|
||||||
"segment_count": 4,
|
"segment_count": 3,
|
||||||
"entities": [
|
"entities": [
|
||||||
{
|
{
|
||||||
"id": "c1",
|
"id": "c1",
|
||||||
@@ -53,11 +57,11 @@ description: >
|
|||||||
"segments": [
|
"segments": [
|
||||||
{
|
{
|
||||||
"index": 0,
|
"index": 0,
|
||||||
"duration": 15,
|
"duration": 12,
|
||||||
"role": "钩子|痛点|卖点|CTA",
|
"role": "钩子|痛点|卖点|CTA",
|
||||||
"narration": "这一镜被说出来的台词/旁白,≤55字",
|
"narration": "这一镜被说出来的台词/旁白,字数上限=duration×3.5",
|
||||||
"speaker": "可选,指向某 entity 的 id;画外旁白时为 null",
|
"speaker": "可选,指向某 entity 的 id;画外旁白时为 null",
|
||||||
"visual": "这一镜的画面:主体+动作+景别/运镜(特写/全景/手持跟拍/推拉摇)+一个画面或情绪的变化,够导演撑满15秒,约40-70字,别只写一句静态动作",
|
"visual": "这一镜的画面:主体+动作+景别/运镜(特写/全景/手持跟拍/推拉摇)+一个画面或情绪的变化,够导演撑满这一镜的秒数,约40-70字,别只写一句静态动作",
|
||||||
"product_exposure": "商品露出方式(手持/特写/使用中)",
|
"product_exposure": "商品露出方式(手持/特写/使用中)",
|
||||||
"entity_refs": ["c1", "s1"],
|
"entity_refs": ["c1", "s1"],
|
||||||
"dialogue": []
|
"dialogue": []
|
||||||
@@ -68,20 +72,29 @@ description: >
|
|||||||
|
|
||||||
字段纪律:
|
字段纪律:
|
||||||
- `tone` 必须是四选一枚举;`role` 必须是四选一枚举。
|
- `tone` 必须是四选一枚举;`role` 必须是四选一枚举。
|
||||||
|
- **表现形式与视频结构由输入给定**:`presentation_format`(`口播|短剧|Vlog`)和
|
||||||
|
`video_structure`(`痛点解决|前后对比|测评验证|场景种草`)**原样回填到输出里**,不要自己改。
|
||||||
|
输入没给时,按商品品类与人群自行选一组最合适的,并如实填进这两个字段。
|
||||||
- **画幅由输入给定**:`aspect_ratio` 默认 `"9:16"`(电商竖屏主场景),但**不写死**——输入指定了其他比例(如 `"16:9"`、`"1:1"`、`"4:5"`)就照用,原样透传给下游。画幅只影响 `visual` 的构图措辞,不改变结构与镜数。
|
- **画幅由输入给定**:`aspect_ratio` 默认 `"9:16"`(电商竖屏主场景),但**不写死**——输入指定了其他比例(如 `"16:9"`、`"1:1"`、`"4:5"`)就照用,原样透传给下游。画幅只影响 `visual` 的构图措辞,不改变结构与镜数。
|
||||||
- **时长档位由输入给定**:`total_duration` 只能取 `15 | 30 | 60 | 90` 之一(输入未指定时默认 `60`)。**不要写死。**
|
- **总时长由输入给定**:`total_duration` 取 **5–60 秒之间的 5 的倍数**(5/10/15/…/60),输入未指定时默认 `30`。**不要写死。**
|
||||||
- **每 15 秒切一镜**:`segment_count = total_duration / 15`,即 15→1 镜、30→2 镜、60→4 镜、90→6 镜;每镜 `duration=15`;`index` 从 0 连续递增(粗暴切,不做复杂时长算法)。
|
- **单镜时长 4–15 秒,允许不等长**:每个 `segments[].duration` 必须是 **4 到 15 之间的整数**
|
||||||
- 各档位的 4 镜功能(role)如何分配/压缩/扩展,见 `references/methodology.md`「档位 × 黄金结构映射」。
|
(15 秒是下游出片模型的硬上限,越界下游直接拒绝出片)。
|
||||||
|
该长的镜给足、该短的镜压短,**不要机械均分**。
|
||||||
|
- **镜时长必须精确加总**:`sum(segments[].duration) == total_duration`,一秒都不能差。
|
||||||
|
- **`segment_count` = `segments` 的实际长度**,且 `index` 从 0 连续递增。
|
||||||
|
- 镜数怎么定、role 怎么按镜数分配,见 `references/methodology.md`「时长 → 镜数 → 黄金结构映射」。
|
||||||
- `entities[].id` 全局唯一,`segments[].entity_refs` 与 `speaker` 只能引用已声明的 id。
|
- `entities[].id` 全局唯一,`segments[].entity_refs` 与 `speaker` 只能引用已声明的 id。
|
||||||
- **发声方式每镜自己判断**(不强求统一,看这一镜的内容和场景):
|
- **发声方式每镜自己判断**(不强求统一,看这一镜的内容和场景):
|
||||||
- **旁白/口播** → 填 `narration`,`dialogue` 留空 `[]`(大多数电商镜是这种);
|
- **旁白/口播** → 填 `narration`,`dialogue` 留空 `[]`(大多数电商镜是这种);
|
||||||
- **角色对话** → 填 `dialogue`:元素为 `{"speaker":"角色 entity 的 id,或 null=旁白","line":"台词"}`,每条 `line` ≤55 字;并把各 `line` 拼进 `narration` 兜底下游字幕/配音。剧情向、多角色互动、或一句自然的吐槽/接话更带感时都可以用,**不必非到「剧情」档**;
|
- **角色对话** → 填 `dialogue`:元素为 `{"speaker":"角色 entity 的 id,或 null=旁白","line":"台词"}`,每条 `line` ≤55 字;并把各 `line` 拼进 `narration` 兜底下游字幕/配音。剧情向、多角色互动、或一句自然的吐槽/接话更带感时都可以用,**不必非到「剧情」档**;
|
||||||
- **纯画面展示** → `narration` 与 `dialogue` 都留空(没人说话,只有画面)。
|
- **纯画面展示** → `narration` 与 `dialogue` 都留空(没人说话,只有画面)。
|
||||||
- **判断权交给你**:依据用户输入与这一镜的功能/场景决定。**不是每个 15 秒都得有对白,也不必死守旁白**;但**别无故给每镜都塞对白**(那样很假),自然才好。
|
- **判断权交给你**:依据用户输入与这一镜的功能/场景决定。**不是每一镜都得有对白,也不必死守旁白**;但**别无故给每镜都塞对白**(那样很假),自然才好。
|
||||||
|
- 发声方式还受**表现形式**约束:口播只用 `narration`、短剧必须用 `dialogue`、Vlog 以画外旁白为主。
|
||||||
|
以 `references/playbooks/format-*.md` 里那一份为准。
|
||||||
- **每个声明的 entity 至少被一个 segment 引用**(不留孤儿 entity)。
|
- **每个声明的 entity 至少被一个 segment 引用**(不留孤儿 entity)。
|
||||||
- **场景必抽,且每镜必绑一个场景**:每条脚本**至少声明 1 个 `type:"scene"` 实体**表示画面所在环境;**每个 segment 的 `entity_refs` 必须恰好引用一个 scene**。多镜在同一环境就**复用同一个 scene id**(绝不为同一环境写两份 visual_prompt,否则下游背景漂移);只有真正换了环境才新建另一个 scene。纯产品特写镜也要绑它所处环境的 scene(如「宿舍书桌」「厨房台面」),没有合适环境时复用主场景。
|
- **场景必抽,且每镜必绑一个场景**:每条脚本**至少声明 1 个 `type:"scene"` 实体**表示画面所在环境;**每个 segment 的 `entity_refs` 必须恰好引用一个 scene**。多镜在同一环境就**复用同一个 scene id**(绝不为同一环境写两份 visual_prompt,否则下游背景漂移);只有真正换了环境才新建另一个 scene。纯产品特写镜也要绑它所处环境的 scene(如「宿舍书桌」「厨房台面」),没有合适环境时复用主场景。
|
||||||
- `visual_prompt` 由你自动生成,小白无需打字。
|
- `visual_prompt` 由你自动生成,小白无需打字。
|
||||||
- **每镜 `visual` 要够厚撑满 15 秒**:一段话写清 ①主体+动作 ②景别/运镜(特写/全景/手持跟拍/推拉摇,至少给一个镜头语言)③一个画面或情绪的变化(从…到…)。约 **40–70 字**,**禁止只写一句静态动作**(如「女主举起商品展示」撑不住 15 秒,要补镜头与变化)。注意:这是给生图/视频导演的画面,**不占 narration 的 55 字额度**。
|
- **每镜 `visual` 要够厚撑满这一镜的秒数**:一段话写清 ①主体+动作 ②景别/运镜(特写/全景/手持跟拍/推拉摇,至少给一个镜头语言)③一个画面或情绪的变化(从…到…)。约 **40–70 字**,**禁止只写一句静态动作**(如「女主举起商品展示」撑不住十几秒,要补镜头与变化)。4–6 秒的短镜可以只给一个动作 + 一个镜头语言,但仍要写清景别。注意:这是给生图/视频导演的画面,**不占 narration 的字数额度**。
|
||||||
- 不要输出 schema 之外的字段,也不要省略必填字段。
|
- 不要输出 schema 之外的字段,也不要省略必填字段。
|
||||||
|
|
||||||
### 铁律 2 · 输出前自检
|
### 铁律 2 · 输出前自检
|
||||||
@@ -93,7 +106,8 @@ description: >
|
|||||||
|
|
||||||
详见 `references/methodology.md`「旁白红线」。最关键的几条:
|
详见 `references/methodology.md`「旁白红线」。最关键的几条:
|
||||||
- **口语化**,像真人对着镜头说话,不准书面腔 / AI 腔。
|
- **口语化**,像真人对着镜头说话,不准书面腔 / AI 腔。
|
||||||
- **每镜 narration ≤ 55 字**(Seedance 15 秒内直接发声,字数 = 可懂语速上限)。
|
- **每镜 narration ≤ `duration × 3.5` 字,且绝不超过 55 字**(出片模型在镜内直接发声,
|
||||||
|
3.5 字/秒是可懂语速上限)。短镜装不下就拆到下一镜,或干脆留空走纯画面。
|
||||||
- **禁违规词**:医疗功效(治疗/根治/抗癌…)、绝对化用语(最/第一/100%/国家级…)一律不写。
|
- **禁违规词**:医疗功效(治疗/根治/抗癌…)、绝对化用语(最/第一/100%/国家级…)一律不写。
|
||||||
- 不浮夸、不空喊,卖点要落到「商品怎么解决痛点」。
|
- 不浮夸、不空喊,卖点要落到「商品怎么解决痛点」。
|
||||||
|
|
||||||
@@ -110,10 +124,12 @@ description: >
|
|||||||
| 参数 | 值 |
|
| 参数 | 值 |
|
||||||
| ---- | ---- |
|
| ---- | ---- |
|
||||||
| 画幅 | **默认 9:16 竖屏**,由输入可覆盖(16:9 / 1:1 / 4:5 等照用) |
|
| 画幅 | **默认 9:16 竖屏**,由输入可覆盖(16:9 / 1:1 / 4:5 等照用) |
|
||||||
| 总时长 | **15 / 30 / 60 / 90 秒四档**,由输入给定;未指定时默认 60 |
|
| 总时长 | **5–60 秒,5 秒步进**,由输入给定;未指定时默认 30 |
|
||||||
| 分镜 | 每 15 秒一镜,均分(粗暴切,不做复杂算法)→ 1 / 2 / 4 / 6 镜 |
|
| 表现形式 | **口播 / 短剧 / Vlog**,由输入给定;未指定时按品类人群自选 |
|
||||||
| 镜头功能 | 钩子 → 痛点 → 卖点 → CTA(黄金结构;不同档位按映射表压缩/扩展,见方法论) |
|
| 视频结构 | **痛点解决 / 前后对比 / 测评验证 / 场景种草**,由输入给定;未指定时按品类人群自选 |
|
||||||
| 发声方式 | Seedance 直接生成画面+音效+人声(**不走 TTS**) |
|
| 分镜 | **单镜 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。
|
1. **路由** — 判定输入模式(①/②/③),加载对应 references;再按输入的表现形式与视频结构加载那两份套路。
|
||||||
2. **定档** — 从输入读取画幅(`aspect_ratio` 默认 9:16)与时长档位(15/30/60/90,未指定默认 60),算出 `segment_count = total_duration / 15`。
|
2. **定镜** — 读画幅(`aspect_ratio` 默认 9:16)与总时长(5–60 秒,未指定默认 30);
|
||||||
3. **定调(tone)** — 依据品类话术 + 平台调性 + 前置条件,选定 `tone`;②③ 模式尊重用户已表达的倾向。
|
按表现形式的推荐单镜节奏定出镜数,再给每镜分配 4–15 秒的时长,**确保加总等于总时长**。
|
||||||
|
3. **定调(tone)** — 依据视频结构 + 品类话术 + 平台调性,选定 `tone`;②③ 模式尊重用户已表达的倾向。
|
||||||
4. **抽取/创建 entities** — 识别脚本需要的角色 / 场景 / 商品;为每个 entity 写一份**全脚本共用**的 `visual_prompt`(保证多镜同一角色同一张脸);可选写 `voice_ref` 锁音色。
|
4. **抽取/创建 entities** — 识别脚本需要的角色 / 场景 / 商品;为每个 entity 写一份**全脚本共用**的 `visual_prompt`(保证多镜同一角色同一张脸);可选写 `voice_ref` 锁音色。
|
||||||
5. **按档位填黄金结构** — 依「档位 × 黄金结构映射表」给每个 segment 分配 `role`;钩子镜套用 `hook-library.md` 的公式。
|
5. **按套路填结构** — 优先用视频结构套路里的骨架给每个 segment 分配 `role`;
|
||||||
6. **写 narration / visual / 商品露出** — 每镜旁白 ≤55 字、口语化、过红线;每镜规划自然的 `product_exposure`。
|
套路没覆盖到的用「role 按镜数分配」表兜底;钩子镜套用该套路指定的钩子写法。
|
||||||
|
6. **写 narration / visual / 商品露出** — 每镜旁白 ≤ `duration × 3.5` 字、口语化、过红线;
|
||||||
|
发声方式按表现形式套路来;每镜规划自然的 `product_exposure`。
|
||||||
7. **连引用** — 填 `entity_refs` 与 `speaker`,确认每个 entity 都被引用、id 都合法。
|
7. **连引用** — 填 `entity_refs` 与 `speaker`,确认每个 entity 都被引用、id 都合法。
|
||||||
8. **自检** — 跑 `checklist.md`,过了再输出。
|
8. **自检** — 跑 `checklist.md`,过了再输出。
|
||||||
9. **输出** — 仅输出铁律 1 的 JSON。
|
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/hook-library.md` | 前 3 秒钩子公式库(痛点提问 / 反差 / 数字冲击 / 身份代入 …,含例句) | 每次生成都读(写钩子镜时) |
|
||||||
| `references/category-playbook.md` | 分品类话术(美妆 / 食品 / 3C / 服饰 / 家居…的语气与卖点侧重) | 全自动 / 一句话模式 |
|
| `references/category-playbook.md` | 分品类话术(美妆 / 食品 / 3C / 服饰 / 家居…的语气与卖点侧重) | 全自动 / 一句话模式 |
|
||||||
| `references/platform-tone.md` | 平台调性(抖音 / 快手 / 小红书 / 视频号 的节奏与风格差异) | 全自动 / 一句话模式 |
|
| `references/platform-tone.md` | 平台调性(抖音 / 快手 / 小红书 / 视频号 的节奏与风格差异) | 全自动 / 一句话模式 |
|
||||||
|
|||||||
@@ -10,9 +10,14 @@
|
|||||||
- [ ] 最终只输出**一个** JSON 对象,无 schema 外的多余文字/注释。
|
- [ ] 最终只输出**一个** JSON 对象,无 schema 外的多余文字/注释。
|
||||||
- [ ] `tone` ∈ `{种草, 测评, 剧情, 痛点}`。
|
- [ ] `tone` ∈ `{种草, 测评, 剧情, 痛点}`。
|
||||||
- [ ] `aspect_ratio` 存在;输入指定了就用输入值,未指定为 `"9:16"`。
|
- [ ] `aspect_ratio` 存在;输入指定了就用输入值,未指定为 `"9:16"`。
|
||||||
- [ ] `total_duration` ∈ `{15, 30, 60, 90}`。
|
- [ ] `presentation_format` ∈ `{口播, 短剧, Vlog}`,且与输入一致(输入没给则自选并如实填写)。
|
||||||
- [ ] `segment_count == total_duration / 15`,且 `segments` 数组长度等于它。
|
- [ ] `video_structure` ∈ `{痛点解决, 前后对比, 测评验证, 场景种草}`,同上。
|
||||||
- [ ] 每个 `segment.duration == 15`;`index` 从 0 连续递增无跳号。
|
- [ ] **组合合法**:不是「短剧 × 测评验证」(唯一禁用组合,见 `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}`。
|
- [ ] 每个 `segment.role` ∈ `{钩子, 痛点, 卖点, CTA}`。
|
||||||
- [ ] 首镜 `role == 钩子`;末镜收 CTA(独立 CTA 镜,或末镜旁白末尾含明确行动指令)。
|
- [ ] 首镜 `role == 钩子`;末镜收 CTA(独立 CTA 镜,或末镜旁白末尾含明确行动指令)。
|
||||||
- [ ] `entities` 每项 `type` ∈ `{character, scene, product}`,`id` 全局唯一。
|
- [ ] `entities` 每项 `type` ∈ `{character, scene, product}`,`id` 全局唯一。
|
||||||
@@ -20,7 +25,7 @@
|
|||||||
- [ ] **每个声明的 entity 至少被一个 segment 引用**(无孤儿 entity)。
|
- [ ] **每个声明的 entity 至少被一个 segment 引用**(无孤儿 entity)。
|
||||||
- [ ] **至少声明 1 个 `type==scene` 实体;每个 segment 的 `entity_refs` 恰好引用一个 scene**(同环境复用同一 scene id,换环境才新建;产品特写镜也要绑所处环境的 scene)。
|
- [ ] **至少声明 1 个 `type==scene` 实体;每个 segment 的 `entity_refs` 恰好引用一个 scene**(同环境复用同一 scene id,换环境才新建;产品特写镜也要绑所处环境的 scene)。
|
||||||
- [ ] `speaker` 要么为 `null`,要么指向一个 `type==character` 的 id。
|
- [ ] `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`;
|
每 entity 有 `id/type/name/visual_prompt/ref_index`;
|
||||||
每 segment 有 `index/duration/role/narration/visual/product_exposure/entity_refs`。
|
每 segment 有 `index/duration/role/narration/visual/product_exposure/entity_refs`。
|
||||||
|
|
||||||
@@ -29,17 +34,22 @@
|
|||||||
- [ ] 钩子镜第一句在前 3 秒抛出钩子,套用了 `hook-library.md` 的某个公式。
|
- [ ] 钩子镜第一句在前 3 秒抛出钩子,套用了 `hook-library.md` 的某个公式。
|
||||||
- [ ] 顶层 `hook` 与钩子镜口径一致。
|
- [ ] 顶层 `hook` 与钩子镜口径一致。
|
||||||
- [ ] 卖点镜的卖点**挂在前面铺的痛点上**,不是干罗列参数。
|
- [ ] 卖点镜的卖点**挂在前面铺的痛点上**,不是干罗列参数。
|
||||||
- [ ] CTA 给了明确动作(点小黄车/领券/主页链接)。
|
- [ ] CTA 给了明确动作(点小黄车/领券/主页链接)。**场景种草结构的 CTA 要软**,别硬喊。
|
||||||
- [ ] 档位映射正确:role 序列符合 `methodology.md`「档位 × 黄金结构映射」表。
|
- [ ] **结构骨架走完了**:段的顺序与功能符合 `playbooks/structure-*.md` 里那份骨架表。
|
||||||
- [ ] 90s 的多个卖点镜**各有侧重不重复**。
|
- [ ] **表现形式的镜头语言到位**:景别变化、单镜时长、人物数量符合 `playbooks/format-*.md`。
|
||||||
|
- [ ] role 序列符合结构套路;套路没覆盖的部分回落到 `methodology.md`「role 按镜数分配」表。
|
||||||
|
- [ ] 多个卖点镜**各有侧重不重复**。
|
||||||
|
- [ ] **逐条过了这两份套路各自的「专属红线」**,一条都没踩。
|
||||||
- [ ] 同一角色/场景/商品全程复用同一 entity(没有给同一对象写出两份 visual_prompt)。
|
- [ ] 同一角色/场景/商品全程复用同一 entity(没有给同一对象写出两份 visual_prompt)。
|
||||||
- [ ] 每个 `visual_prompt` 信息足够喂图模型(角色/场景/商品的外观特征写清)。
|
- [ ] 每个 `visual_prompt` 信息足够喂图模型(角色/场景/商品的外观特征写清)。
|
||||||
- [ ] 每镜 `product_exposure` 自然、与 role 匹配(参考方法论露出表)。
|
- [ ] 每镜 `product_exposure` 自然、与 role 匹配(参考方法论露出表)。
|
||||||
|
|
||||||
## C. 旁白红线扫描(逐镜)
|
## C. 旁白红线扫描(逐镜)
|
||||||
|
|
||||||
- [ ] 每镜 `narration` ≤ 55 字(硬上限;目标 ≤50 留缓冲,逐字数一遍,别凭感觉)。
|
- [ ] **每镜 `narration` ≤ `duration × 3.5` 字,且绝不超过 55 字**(逐镜按自己的秒数数一遍,别凭感觉。
|
||||||
- [ ] 每镜 `visual` ≥ 40 字且含「景别/运镜 + 一个画面变化」,不是一句静态动作(撑满 15 秒)。
|
4 秒镜 ≤14 字、8 秒镜 ≤28 字、15 秒镜 ≤52 字,对照表见 `methodology.md`)。
|
||||||
|
- [ ] 每镜 `visual` 含「景别/运镜 + 一个画面变化」,不是一句静态动作;能撑满这一镜的秒数
|
||||||
|
(≥8 秒的镜约 40–70 字;4–6 秒的短镜可以更短,但景别必须写清)。
|
||||||
- [ ] 口语化,无书面腔/AI 腔("综上""不仅…而且""值得一提"等已清除)。
|
- [ ] 口语化,无书面腔/AI 腔("综上""不仅…而且""值得一提"等已清除)。
|
||||||
- [ ] **违规词扫描**:无医疗功效词(治疗/根治/抗癌/消炎/排毒/速效…)。
|
- [ ] **违规词扫描**:无医疗功效词(治疗/根治/抗癌/消炎/排毒/速效…)。
|
||||||
- [ ] **绝对化用语扫描**:无 最/第一/唯一/100%/国家级/永久/绝对/史上 等。
|
- [ ] **绝对化用语扫描**:无 最/第一/唯一/100%/国家级/永久/绝对/史上 等。
|
||||||
|
|||||||
@@ -22,22 +22,50 @@
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 二、档位 × 黄金结构映射(关键)
|
## 二、时长 → 镜数 → 黄金结构映射(关键)
|
||||||
|
|
||||||
时长由输入给定(15/30/60/90 四档),每 15 秒一镜,`segment_count = total_duration / 15`。
|
### 2.1 时长规则(不再是固定四档,也不再每镜等长)
|
||||||
镜数变了,四个功能要**压缩或扩展**,按下表分配 `role`:
|
|
||||||
|
|
||||||
| 档位 | 镜数 | role 序列(index 0→N) | 压缩/扩展说明 |
|
- **总时长**由输入给定:**5–60 秒之间、5 秒一档**(5/10/15/…/60)。未指定时默认 30。
|
||||||
| ---- | ---- | ---- | ---- |
|
- **单镜时长 4–15 秒**,这是下游出片模型的硬上限,**任何一镜都不许超过 15 秒、不许低于 4 秒**。
|
||||||
| **15s** | 1 | `[钩子]` | 一镜到底:开头 3 秒钩子 → 中段一句卖点 → 末尾一句 CTA,全压进这 15 秒旁白里 |
|
- **每镜时长可以不一样。** 该长的镜(讲卖点、举证)就给足,该短的镜(空镜、转场、一句钩子)就压短。
|
||||||
| **30s** | 2 | `[钩子, 卖点]` | 镜0 钩子里带出痛点;镜1 讲卖点并在旁白末尾收一句 CTA |
|
别再机械均分。
|
||||||
| **60s** | 4 | `[钩子, 痛点, 卖点, CTA]` | 标准黄金结构,一镜一功能 |
|
- 所有 `segments[].duration` 相加**必须精确等于** `total_duration`。
|
||||||
| **90s** | 6 | `[钩子, 痛点, 卖点, 卖点, 卖点, CTA]` | 痛点后给 3 个卖点镜:核心卖点 / 场景化演示 / 信任背书(口碑·数据);末镜 CTA |
|
|
||||||
|
### 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` 四个枚举值。"信任背书""场景演示"等都归入 `卖点`。
|
- `role` 只能取 `钩子|痛点|卖点|CTA` 四个枚举值。"信任背书""场景演示""前后对比"等都归入 `卖点`。
|
||||||
- 短档位(15/30s)靠**旁白内压缩多功能**达成"留人+转化",不要因为镜少就丢掉 CTA。
|
- **镜少也不许丢 CTA** —— 镜数 ≤2 时把 CTA 压进末镜旁白的最后一句。
|
||||||
- 90s 的 3 个卖点镜要**各有侧重、不重复**(核心功效 / 真实使用场景 / 别人为什么买)。
|
- 多个卖点镜要**各有侧重、不重复**(核心功效 / 真实使用场景 / 别人为什么买 / 举证读数)。
|
||||||
|
- 视频结构会覆盖这里的默认分配 —— 以 `playbooks/structure-*.md` 里的骨架为准,
|
||||||
|
本表只是没有指定结构时的兜底。
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -74,8 +102,22 @@
|
|||||||
## 五、旁白红线(硬规则,违反即不合格)
|
## 五、旁白红线(硬规则,违反即不合格)
|
||||||
|
|
||||||
- **口语化**:像真人对着镜头唠嗑,不准书面腔 / 不准 AI 腔("综上所述""不仅…而且""值得一提的是"全禁)。
|
- **口语化**:像真人对着镜头唠嗑,不准书面腔 / 不准 AI 腔("综上所述""不仅…而且""值得一提的是"全禁)。
|
||||||
- **每镜 `narration` ≤ 55 字**:Seedance 在 15 秒内直接发声,55 字是可懂语速**硬上限**。
|
- **`narration` 字数按这一镜的秒数算**:出片模型在镜内直接发声,可懂语速上限约 **3.5 字 / 秒**。
|
||||||
**写作目标 ≤ 50 字**,留 5 字缓冲——宁可短、不要卡满;短档位(15/30s)一镜要装多功能时尤其要狠删,先保 CTA 不被砍。
|
**每镜 `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 不被砍。
|
||||||
- **不浮夸、不空喊**:卖点要给具体理由,不堆形容词。
|
- **不浮夸、不空喊**:卖点要给具体理由,不堆形容词。
|
||||||
- **违规词禁令**(电商广告法红线,一律不写):
|
- **违规词禁令**(电商广告法红线,一律不写):
|
||||||
- **医疗功效类**:治疗 / 根治 / 疗效 / 抗癌 / 消炎 / 杀菌(无证) / 排毒 / 速效 / 抑制 ××病 …
|
- **医疗功效类**:治疗 / 根治 / 疗效 / 抗癌 / 消炎 / 杀菌(无证) / 排毒 / 速效 / 抑制 ××病 …
|
||||||
|
|||||||
@@ -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」三件,其余全砍。
|
||||||
@@ -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 处,不是结尾
|
||||||
|
- ❌ **角色超过三个** —— 观众记不住
|
||||||
|
- ❌ **主角自己夸商品** —— 让副角说,或让结果说
|
||||||
|
- ❌ **冲突是抽象的** —— 「最近好烦」不算冲突,「客户又打回来第五版」才算
|
||||||
|
- ❌ **结局只靠台词交代** —— 反转必须在画面上看得见
|
||||||
|
- ❌ **把测评做成短剧** —— 演出来的测评没有可信度,这个组合是禁用的
|
||||||
@@ -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 次景别变化
|
||||||
|
- ❌ **念稿腔** —— 短句、口语、允许有语气词(「说真的」「你听我讲完」)
|
||||||
|
- ❌ **多个人轮流说** —— 那是短剧或访谈,不是口播
|
||||||
|
- ❌ **台词写成书面语** —— 「其具备优异的保湿性能」→「它是真的锁水,我这脸一天不干」
|
||||||
@@ -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 要软(「链接我放下面了」而不是「快点小黄车」)
|
||||||
|
- ❌ **摆拍痕迹重** —— 完美构图反而失真,允许不完美
|
||||||
@@ -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
|
||||||
|
|
||||||
|
**别用台词描述画面已经展示的东西。** 「你看,明显干净多了」是废话,观众看得见。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 七、本结构红线
|
||||||
|
|
||||||
|
- ❌ **前后条件不一致** —— 换光、换角度、换妆容,一律判假
|
||||||
|
- ❌ **跳过使用过程** —— 直接从「之前」切「之后」,观众不信
|
||||||
|
- ❌ **对比两个以上维度** —— 注意力分散,一个都记不住
|
||||||
|
- ❌ **时间含糊** —— 必须给明确时长
|
||||||
|
- ❌ **夸大反转幅度** —— 效果超出商品实际能力就是虚假宣传
|
||||||
|
- ❌ **用台词代替画面** —— 这条结构的说服力在画面,不在嘴
|
||||||
@@ -0,0 +1,94 @@
|
|||||||
|
# 视频结构 · 痛点解决
|
||||||
|
|
||||||
|
> 先把观众的痛戳到,再把商品端上来。电商转化率最稳的一条结构,适用面最广。
|
||||||
|
> 核心风险:痛点不够具体 → 观众不对号入座 → 整条视频白做。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 一、结构骨架
|
||||||
|
|
||||||
|
```
|
||||||
|
[ 痛点场景 ] → [ 痛点放大 ] → [ 商品介入 ] → [ 结果 + CTA ]
|
||||||
|
↑ 必须具体到可复现的一幕
|
||||||
|
```
|
||||||
|
|
||||||
|
| 段 | 功能 | role | 判断标准 |
|
||||||
|
| ---- | ---- | ---- | ---- |
|
||||||
|
| **1 · 痛点场景** | 让观众「这就是我」 | `钩子` | 一个具体的、有画面的、发生在昨天的场景 |
|
||||||
|
| **2 · 痛点放大** | 让观众意识到代价 | `痛点` | 说出这个麻烦带来的**后果**,不只是麻烦本身 |
|
||||||
|
| **3 · 商品介入** | 给出解法 | `卖点` | 卖点必须**正面对准**前面的痛点,一一对应 |
|
||||||
|
| **4 · 结果 + CTA** | 转化 | `CTA` | 结果要可见,CTA 要有动作 |
|
||||||
|
|
||||||
|
时长短时按此顺序压缩,段可以合并但**顺序不能变**。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 二、痛点怎么写才算合格
|
||||||
|
|
||||||
|
**唯一标准:具体到能拍出来。**
|
||||||
|
|
||||||
|
| ❌ 不合格 | ✅ 合格 |
|
||||||
|
| ---- | ---- |
|
||||||
|
| 头发很油 | 早上洗的头,下午三点开会刘海已经一绺一绺 |
|
||||||
|
| 收纳很乱 | 每次找充电线要翻三个抽屉,最后在沙发缝里 |
|
||||||
|
| 皮肤状态不好 | 昨晚两点睡,今早粉底卡在法令纹里 |
|
||||||
|
| 做饭很麻烦 | 下班到家七点半,等米饭熟就八点十分 |
|
||||||
|
|
||||||
|
**方法:给痛点加时间、加地点、加一个具体后果。** 抽象的痛点没有人会认领。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 三、痛点放大的分寸
|
||||||
|
|
||||||
|
放大是为了让观众意识到「这事得解决」,不是为了制造焦虑。
|
||||||
|
|
||||||
|
- ✅ 放大**代价**:耽误的时间、多花的钱、错过的场合、别人的眼光
|
||||||
|
- ❌ 不许贩卖容貌焦虑、身材焦虑、年龄焦虑
|
||||||
|
- ❌ 不许暗示不用这个商品会有健康后果(这条越线就是违规)
|
||||||
|
|
||||||
|
**一句话就够。** 放大段是全片最短的,超过一句就变成说教。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 四、卖点必须一一对应
|
||||||
|
|
||||||
|
这是「痛点解决」结构最容易翻车的地方。
|
||||||
|
|
||||||
|
**规则:前面戳了几个痛点,后面就只讲几个卖点,且必须一一对上。**
|
||||||
|
|
||||||
|
```
|
||||||
|
痛点:下午三点头发就油
|
||||||
|
↓ 必须对上
|
||||||
|
卖点:它的控油是靠 XX 成分吸附,我实测撑到晚上八点
|
||||||
|
```
|
||||||
|
|
||||||
|
**禁止**在这里罗列商品的全部卖点。跟痛点无关的卖点一个都不要提 —— 它们只会
|
||||||
|
稀释说服力。一条视频解决一个痛点就够了。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 五、结果与 CTA
|
||||||
|
|
||||||
|
- **结果必须可见**:同一个场景、同一个时间点,状态变了
|
||||||
|
- CTA 给**一个**明确动作,不要给两个(「点小黄车」和「关注我」二选一)
|
||||||
|
- 紧迫感可以有,但不许用绝对化用语(「最后一天」可以,「全网最低」不行)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 六、钩子写法(第一镜)
|
||||||
|
|
||||||
|
痛点解决结构的钩子优先用这三种:
|
||||||
|
|
||||||
|
1. **痛点提问** —「你有没有过这种情况:___?」
|
||||||
|
2. **场景直击** — 直接演那一幕,不解释(画面自己说话)
|
||||||
|
3. **共犯身份** —「我们这种___的人,都懂。」
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 七、本结构红线
|
||||||
|
|
||||||
|
- ❌ **痛点抽象** —— 最致命,写不具体就重写
|
||||||
|
- ❌ **卖点跟痛点对不上** —— 观众会觉得答非所问
|
||||||
|
- ❌ **一条视频戳三个痛点** —— 一个就够,多了就散
|
||||||
|
- ❌ **贩卖焦虑** —— 放大代价可以,制造恐慌不行
|
||||||
|
- ❌ **痛点段太长** —— 痛点占比不超过全片一半,否则观众会难受到划走
|
||||||
@@ -0,0 +1,98 @@
|
|||||||
|
# 视频结构 · 测评验证
|
||||||
|
|
||||||
|
> 用举证的方式说服。观众要的不是「你说它好」,而是「你怎么证明它好」。
|
||||||
|
> 客单价越高,这条结构越有效。核心风险:只有结论没有证据 → 变成软广。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 一、结构骨架
|
||||||
|
|
||||||
|
```
|
||||||
|
[ 提出质疑 ] → [ 设定验证方法 ] → [ 当场验证 ] → [ 给出结论 + CTA ]
|
||||||
|
↑ 站在观众那一边,而不是商品那一边
|
||||||
|
```
|
||||||
|
|
||||||
|
| 段 | 功能 | role | 判断标准 |
|
||||||
|
| ---- | ---- | ---- | ---- |
|
||||||
|
| **1 · 提出质疑** | 建立中立立场 | `钩子` | 用观众的口吻问出怀疑,不许一开始就夸 |
|
||||||
|
| **2 · 验证方法** | 建立方法可信 | `痛点` | 说清「我要怎么测」,方法要看起来公平 |
|
||||||
|
| **3 · 当场验证** | 举证 | `卖点` | 必须有**可见的过程和读数**,不能只给结论 |
|
||||||
|
| **4 · 结论 + CTA** | 转化 | `CTA` | 结论要有保留(说清适合谁不适合谁) |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 二、立场问题(本结构的灵魂)
|
||||||
|
|
||||||
|
测评验证跟其他结构最大的区别:**你不站在商品这边,你站在观众这边。**
|
||||||
|
|
||||||
|
- 开场是**质疑**,不是推荐:
|
||||||
|
- ✅「这瓶卖 299,说能扛住一整天。我不太信,今天实测。」
|
||||||
|
- ❌「今天给大家推荐一款超好用的产品。」
|
||||||
|
- 全片语气是**验证**,不是介绍
|
||||||
|
- 结论必须**有保留**,全是好话反而没人信
|
||||||
|
|
||||||
|
**如果整条视频没有一句对商品不利的话,观众会判定这是广告。**
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 三、证据类型(至少给两种)
|
||||||
|
|
||||||
|
| 证据类型 | 呈现方式 | 可信度 |
|
||||||
|
| ---- | ---- | ---- |
|
||||||
|
| **实测读数** | 计时器 / 温度计 / 秤 / 尺 —— 画面上要看得见数字 | 最高 |
|
||||||
|
| **对照组** | 同时测另一个东西作参照 | 高 |
|
||||||
|
| **过程可见** | 完整拍出使用过程,不剪断 | 高 |
|
||||||
|
| **时间跨度** | 「连续用了 7 天」+ 每天记录 | 中高 |
|
||||||
|
| **第三方背书** | 检测报告 / 成分表 / 销量 | 中(不能单独用) |
|
||||||
|
| **主观感受** | 「我觉得挺好用」 | 最低(不能单独用) |
|
||||||
|
|
||||||
|
**铁律:至少要有一种是可见的客观证据(读数或对照组)。** 全靠主观感受的
|
||||||
|
「测评」就是伪装成测评的口播。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 四、诚实分寸
|
||||||
|
|
||||||
|
- **必须说一个缺点。** 缺点要真实但不致命(价格偏高、颜色少、需要适应期)
|
||||||
|
- 说清**适合谁、不适合谁** —— 这既是诚实,也是精准筛选目标客户
|
||||||
|
- 不许伪造读数、不许剪掉不利结果
|
||||||
|
- 不许用绝对化用语和医疗功效词(这条结构最容易踩,因为在「讲效果」)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 五、钩子写法(第一镜)
|
||||||
|
|
||||||
|
1. **数字质疑** —「标称 8 小时,我测测到底几小时。」
|
||||||
|
2. **价格质疑** —「299 一瓶,凭什么?」
|
||||||
|
3. **打脸预告** —「我本来想吐槽它的,结果……」(反转型,很抓人)
|
||||||
|
4. **对照开场** — 直接把两个东西并排放在画面里
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 六、镜头语言
|
||||||
|
|
||||||
|
- **验证过程必须是长镜或连续镜**,频繁跳剪会让人怀疑剪掉了什么
|
||||||
|
- 读数、仪表、计时器要给**特写**,让观众自己看清
|
||||||
|
- 人可以出画外只留手,重点在被测的东西上
|
||||||
|
- 对照组要**同框**,不要分别拍
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 七、时长注意
|
||||||
|
|
||||||
|
测评验证是**唯一适合做长的结构**。举证需要时间,压得太短就没有证据密度。
|
||||||
|
|
||||||
|
- 低于 20 秒时,只能给一种证据 —— 优先给实测读数
|
||||||
|
- 推荐 45–60 秒,能完整走完「质疑 → 方法 → 验证 → 结论」
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 八、本结构红线
|
||||||
|
|
||||||
|
- ❌ **开场就夸** —— 立场立刻崩塌,后面说什么都没用
|
||||||
|
- ❌ **只有结论没有过程** —— 那不是测评,是广告
|
||||||
|
- ❌ **全是优点没有缺点** —— 观众判定为软广
|
||||||
|
- ❌ **证据全是主观感受** —— 至少要有一个客观读数
|
||||||
|
- ❌ **伪造或含糊读数** —— 涉嫌虚假宣传
|
||||||
|
- ❌ **做成短剧演出来** —— 演绎的测评没有可信度,这个组合是禁用的
|
||||||
|
- ❌ **医疗功效词 / 绝对化用语** —— 本结构最高发区,输出前重点扫描
|
||||||
@@ -0,0 +1,105 @@
|
|||||||
|
# 视频结构 · 场景种草
|
||||||
|
|
||||||
|
> 不讲痛点、不做对比、不举证,只把商品放进一个让人向往的场景里。
|
||||||
|
> 观众买的是那个场景,商品是入场券。核心风险:光有氛围没有商品记忆点。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 一、结构骨架
|
||||||
|
|
||||||
|
```
|
||||||
|
[ 场景建立 ] → [ 商品自然入场 ] → [ 场景因它更好 ] → [ 软 CTA ]
|
||||||
|
↑ 场景本身要有吸引力,不靠商品撑
|
||||||
|
```
|
||||||
|
|
||||||
|
| 段 | 功能 | role | 判断标准 |
|
||||||
|
| ---- | ---- | ---- | ---- |
|
||||||
|
| **1 · 场景建立** | 制造向往 | `钩子` | 光、空间、氛围先立住,**商品先不出现** |
|
||||||
|
| **2 · 商品入场** | 自然带出 | `卖点` | 商品是被**使用**的,不是被展示的 |
|
||||||
|
| **3 · 场景升级** | 建立关联 | `卖点` | 让观众看到「有了它,这个场景更完整」 |
|
||||||
|
| **4 · 软 CTA** | 转化 | `CTA` | 语气要轻,不能破坏氛围 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 二、场景怎么选
|
||||||
|
|
||||||
|
**规则:选一个观众想进入、但门槛不高的场景。**
|
||||||
|
|
||||||
|
| ✅ 好场景 | ❌ 坏场景 |
|
||||||
|
| ---- | ---- |
|
||||||
|
| 周末早晨的厨房,光从窗户斜进来 | 豪华别墅(太远,无法代入) |
|
||||||
|
| 加班后回到家的第一件事 | 泛泛的「日常生活」(无记忆点) |
|
||||||
|
| 露营地的傍晚,炉子上在烧水 | 摄影棚白底(没有场景) |
|
||||||
|
| 出差酒店,把随身的小东西摆开 | 商品堆在桌上的平铺展示 |
|
||||||
|
|
||||||
|
**判断标准:观众看完能说出「这是什么时候、什么地方」,才算场景立住了。**
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 三、氛围要素(至少给三样)
|
||||||
|
|
||||||
|
场景种草的说服力来自细节密度。每条脚本至少铺三样:
|
||||||
|
|
||||||
|
- **光**:晨光 / 夕照 / 台灯 / 烛光 —— 光决定情绪,必须明确写进 `visual`
|
||||||
|
- **声音**:水沸 / 键盘 / 雨 / 风 —— 写进 `visual` 提示环境音
|
||||||
|
- **触感**:蒸汽、毛毯的绒、杯壁的温度 —— 让画面有温度
|
||||||
|
- **时间**:具体到时刻(清晨六点 / 下午三点 / 睡前)
|
||||||
|
- **陪衬物**:书、猫、外卖盒、耳机 —— 让场景像真的有人生活
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 四、商品怎么出场
|
||||||
|
|
||||||
|
**铁律:商品必须在「被使用」的状态下出现,不许摆拍展示。**
|
||||||
|
|
||||||
|
| ✅ | ❌ |
|
||||||
|
| ---- | ---- |
|
||||||
|
| 手拿起杯子喝了一口,杯子在画面里 | 杯子放在桌上转一圈的产品镜 |
|
||||||
|
| 把它挂在帐篷上,光亮起来 | 手举着它对镜头展示 |
|
||||||
|
| 穿着它走过街角 | 平铺在床上拍细节 |
|
||||||
|
|
||||||
|
**但商品必须被看清。** 至少要有一个镜头能让观众认出商品的外形和品牌。
|
||||||
|
这是场景种草最容易丢的东西 —— 氛围拉满,观众记住了氛围,没记住商品。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 五、钩子写法(第一镜)
|
||||||
|
|
||||||
|
场景种草的钩子是**画面**,不是话。
|
||||||
|
|
||||||
|
1. **氛围直给** — 第一帧就是最好看的那一帧,不解释
|
||||||
|
2. **时刻宣告** —「周六早上八点,我唯一不设闹钟的一天。」
|
||||||
|
3. **动作开场** — 从一个具体动作切入(拉开窗帘、按下开关)
|
||||||
|
|
||||||
|
**不要用提问式钩子**,那是痛点解决的写法,会破坏场景的沉浸感。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 六、台词分寸
|
||||||
|
|
||||||
|
- 语气是**自述或旁白**,轻、慢、留白
|
||||||
|
- **不讲参数、不讲价格、不讲功效** —— 这些会立刻把观众从场景里踢出来
|
||||||
|
- 可以整镜无声,让环境音和画面说话
|
||||||
|
- CTA 要软:「链接放下面了」而不是「快去抢」
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 七、跟其他结构的边界
|
||||||
|
|
||||||
|
场景种草**不承担说服功能**,它承担的是「想要」功能。
|
||||||
|
|
||||||
|
- 观众看完不会觉得「我需要」,而是「我想要」
|
||||||
|
- 所以它**不适合客单价高的商品**(高客单需要理由,要用测评验证)
|
||||||
|
- 适合:食品饮料、家居小物、香氛、服饰配件、露营户外
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 八、本结构红线
|
||||||
|
|
||||||
|
- ❌ **商品没被看清** —— 最常见的失败,氛围盖过商品
|
||||||
|
- ❌ **讲参数或价格** —— 立刻破坏沉浸感
|
||||||
|
- ❌ **摆拍式产品展示镜** —— 商品只在被使用时出现
|
||||||
|
- ❌ **场景太远太贵** —— 观众代入不了就没有种草效果
|
||||||
|
- ❌ **用提问式钩子** —— 那是痛点结构的写法
|
||||||
|
- ❌ **硬 CTA** —— 「三、二、一上链接」会毁掉整条视频的调性
|
||||||
|
- ❌ **场景描述抽象** —— 「温馨的家」不算场景,要有光、有时间、有物件
|
||||||
@@ -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 标题符号(`#`)和代码块围栏。
|
||||||
@@ -21,6 +21,7 @@ import type {
|
|||||||
import { publicModelDisplayName } from "./model-display";
|
import { publicModelDisplayName } from "./model-display";
|
||||||
import { generationErrorText } from "./generation-error";
|
import { generationErrorText } from "./generation-error";
|
||||||
import { CornerMarks, Decorations, Sidebar, ToastLike } from "./components/app-shell";
|
import { CornerMarks, Decorations, Sidebar, ToastLike } from "./components/app-shell";
|
||||||
|
import { SystemLoading } from "./components/loading";
|
||||||
import {
|
import {
|
||||||
AccountPage,
|
AccountPage,
|
||||||
AssetFactoryPage,
|
AssetFactoryPage,
|
||||||
@@ -769,20 +770,12 @@ export function App() {
|
|||||||
|
|
||||||
if (booting || !user || !team) {
|
if (booting || !user || !team) {
|
||||||
return (
|
return (
|
||||||
<div className="app">
|
<SystemLoading
|
||||||
<main>
|
variant="fullscreen"
|
||||||
<div className="content">
|
title="正在进入工作台"
|
||||||
<div className="page-head">
|
description="正在同步账号与团队数据,请稍候。"
|
||||||
<div>
|
icon="layoutDashboard"
|
||||||
<h1>正在进入工作台…</h1>
|
/>
|
||||||
<div className="sub">
|
|
||||||
<span className="mono">// 正在拉取团队数据</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</main>
|
|
||||||
</div>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -985,31 +978,22 @@ export function App() {
|
|||||||
// 详情还在拉取(刚切项目 / 首进管线):显示全屏加载占位,等完整详情到位再渲染,而不是拿轻量数据去崩。
|
// 详情还在拉取(刚切项目 / 首进管线):显示全屏加载占位,等完整详情到位再渲染,而不是拿轻量数据去崩。
|
||||||
// 拉取多次仍失败则给重试 + 返回,避免「永久卡 loading 进不来」。
|
// 拉取多次仍失败则给重试 + 返回,避免「永久卡 loading 进不来」。
|
||||||
if (page === "pipeline" && activeProjectId && !pipelineProject) {
|
if (page === "pipeline" && activeProjectId && !pipelineProject) {
|
||||||
|
const projectRef = activeProjectId.slice(0, 8).toUpperCase();
|
||||||
return (
|
return (
|
||||||
<div className="app">
|
<SystemLoading
|
||||||
<main>
|
variant="fullscreen"
|
||||||
<div className="content">
|
state={projectDetailError ? "error" : "loading"}
|
||||||
<div className="page-head">
|
title={projectDetailError ? "项目加载失败" : "正在打开项目工作台"}
|
||||||
<div>
|
description={projectDetailError ? "暂时无法连接服务,请重试或返回项目列表。" : "正在同步项目数据,请稍候。"}
|
||||||
<h1>{projectDetailError ? "项目加载失败" : "加载中…"}</h1>
|
icon={projectDetailError ? "activity" : "clapperboard"}
|
||||||
<div className="sub">
|
reference={projectRef}
|
||||||
<span className="mono">{projectDetailError ? "// 网络异常或服务繁忙" : "// 正在拉取项目数据"}</span>
|
actions={projectDetailError ? (
|
||||||
</div>
|
<>
|
||||||
</div>
|
<button className="btn btn-primary" type="button" onClick={() => setDetailRetry((n) => n + 1)}>重试</button>
|
||||||
{projectDetailError && (
|
<button className="btn btn-ghost" type="button" onClick={() => navigate("projects")}>返回项目列表</button>
|
||||||
<div className="actions">
|
</>
|
||||||
<button className="btn btn-primary" type="button" onClick={() => setDetailRetry((n) => n + 1)}>
|
) : undefined}
|
||||||
重试
|
/>
|
||||||
</button>
|
|
||||||
<button className="btn btn-ghost" type="button" onClick={() => navigate("projects")}>
|
|
||||||
返回项目列表
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</main>
|
|
||||||
</div>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
if (page === "pipeline" && pipelineProject) {
|
if (page === "pipeline" && pipelineProject) {
|
||||||
|
|||||||
@@ -198,6 +198,20 @@
|
|||||||
|
|
||||||
/* ── 资产审核队列 ── */
|
/* ── 资产审核队列 ── */
|
||||||
.admin-table .col-check { width: 36px; text-align: center; }
|
.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 {
|
.admin-thumb {
|
||||||
width: 40px;
|
width: 40px;
|
||||||
height: 40px;
|
height: 40px;
|
||||||
@@ -206,6 +220,7 @@
|
|||||||
background: var(--background-lighter);
|
background: var(--background-lighter);
|
||||||
border: 1px solid var(--border-faint);
|
border: 1px solid var(--border-faint);
|
||||||
display: block;
|
display: block;
|
||||||
|
pointer-events: none;
|
||||||
}
|
}
|
||||||
.admin-thumb-empty { display: inline-block; }
|
.admin-thumb-empty { display: inline-block; }
|
||||||
.admin-review-err {
|
.admin-review-err {
|
||||||
@@ -220,14 +235,14 @@
|
|||||||
font-size: 11px;
|
font-size: 11px;
|
||||||
cursor: help;
|
cursor: help;
|
||||||
}
|
}
|
||||||
/* 收窄到 .admin-app 作用域:`.bulk-bar` 是全局裸类(商品库/项目/资产库各有黑底版),
|
/* 独立类名,避开商品库/项目页全局黑底 `.bulk-bar button`(白字透明边),
|
||||||
不限定会随 main.tsx 末位加载盖掉那些黑底浮条 → 全站批量栏被染白。 */
|
否则管理台白底浮条上按钮文字不可见,只剩一条扁边框。 */
|
||||||
.admin-app .bulk-bar {
|
.admin-bulk-bar {
|
||||||
position: fixed;
|
position: fixed;
|
||||||
left: 50%;
|
left: 50%;
|
||||||
bottom: 28px;
|
bottom: 28px;
|
||||||
transform: translateX(-50%);
|
transform: translateX(-50%);
|
||||||
display: flex;
|
display: inline-flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 12px;
|
gap: 12px;
|
||||||
padding: 10px 16px;
|
padding: 10px 16px;
|
||||||
@@ -237,7 +252,12 @@
|
|||||||
box-shadow: var(--shadow-floating);
|
box-shadow: var(--shadow-floating);
|
||||||
z-index: 50;
|
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; }
|
.admin-anomaly-chip { height: 28px; padding: 0 14px; font-size: 12.5px; cursor: pointer; }
|
||||||
|
|||||||
@@ -44,6 +44,7 @@ import type {
|
|||||||
UserPreference,
|
UserPreference,
|
||||||
VoiceoverInfo
|
VoiceoverInfo
|
||||||
} from "./types";
|
} from "./types";
|
||||||
|
import type { PresentationFormat, VideoStructure } from "./script-setup";
|
||||||
|
|
||||||
const API_BASE = import.meta.env.VITE_API_BASE_URL || "";
|
const API_BASE = import.meta.env.VITE_API_BASE_URL || "";
|
||||||
const TOKEN_KEY = "airshelf_token";
|
const TOKEN_KEY = "airshelf_token";
|
||||||
@@ -326,6 +327,21 @@ export const api = {
|
|||||||
purgeProject(id: string) {
|
purgeProject(id: string) {
|
||||||
return request<void>(`/api/projects/${id}/purge/`, { method: "DELETE" });
|
return request<void>(`/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。
|
// 对话式脚本 agent · 流式(SSE)。逐帧回调 onEvent:tool(工具卡)/delta(思考前言)/draft/saved/done/error。
|
||||||
// 用 fetch + ReadableStream 消费 text/event-stream(EventSource 只支持 GET,这里要 POST 带 body)。
|
// 用 fetch + ReadableStream 消费 text/event-stream(EventSource 只支持 GET,这里要 POST 带 body)。
|
||||||
async agentScriptStream(
|
async agentScriptStream(
|
||||||
@@ -338,7 +354,12 @@ export const api = {
|
|||||||
base_version_id?: string;
|
base_version_id?: string;
|
||||||
aspect_ratio?: string;
|
aspect_ratio?: string;
|
||||||
total_duration?: number;
|
total_duration?: number;
|
||||||
|
// 二期:表现形式 × 视频结构 —— 后端按这两个挑套路提示词,不传则回落 口播 × 痛点解决
|
||||||
|
presentation_format?: PresentationFormat;
|
||||||
|
video_structure?: VideoStructure;
|
||||||
target_index?: number;
|
target_index?: number;
|
||||||
|
// 哪个入口来的:辅助生成 / 上传脚本 / 上传视频提炼。三者都是 mode=auto,靠它区分并记进脚本「来源」
|
||||||
|
source?: "ai" | "manual" | "video";
|
||||||
},
|
},
|
||||||
onEvent: (evt: { type: string; [k: string]: unknown }) => void,
|
onEvent: (evt: { type: string; [k: string]: unknown }) => void,
|
||||||
signal?: AbortSignal
|
signal?: AbortSignal
|
||||||
@@ -542,9 +563,13 @@ export const api = {
|
|||||||
videoPacks() {
|
videoPacks() {
|
||||||
return request<import("./types").VideoPack[]>("/api/assets/video-packs/");
|
return request<import("./types").VideoPack[]>("/api/assets/video-packs/");
|
||||||
},
|
},
|
||||||
// 手动兜底:把单个送审类素材(角色/三视图/分镜)送火山审核(用户点灰盾「待审核」时)
|
// 手动兜底:把单个送审类素材(角色/三视图/分镜)送火山审核(用户点灰盾「待审核」时)。
|
||||||
submitAssetReview(id: string) {
|
// forReference:素材要拿去当生成参考(自由创作 @引用三库),此时不看类目白名单一律送审。
|
||||||
return request<{ review_status: string; review_error: string }>(`/api/assets/${id}/submit-review/`, { method: "POST" });
|
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(功能性模特/角色资料不走该入口;图片成图仍按原规则入库)
|
// 置 in_library(功能性模特/角色资料不走该入口;图片成图仍按原规则入库)
|
||||||
setAssetLibrary(id: string, inLibrary: boolean) {
|
setAssetLibrary(id: string, inLibrary: boolean) {
|
||||||
|
|||||||
@@ -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;
|
mode: FreeMode;
|
||||||
model: string;
|
model: string;
|
||||||
ratio: string;
|
ratio: string;
|
||||||
@@ -81,6 +81,7 @@ export function FreeInputBar({ mode, model, ratio, resolution, duration, seed, r
|
|||||||
onDurationChange: (duration: number) => void;
|
onDurationChange: (duration: number) => void;
|
||||||
onSeedChange: (seed: number) => void;
|
onSeedChange: (seed: number) => void;
|
||||||
onOpenLibrary: (role?: "first_frame" | "last_frame") => void;
|
onOpenLibrary: (role?: "first_frame" | "last_frame") => void;
|
||||||
|
onOpenPlatformLibrary: (role?: "first_frame" | "last_frame") => void;
|
||||||
onClear: () => void;
|
onClear: () => void;
|
||||||
onSend: () => void;
|
onSend: () => void;
|
||||||
}) {
|
}) {
|
||||||
@@ -134,6 +135,9 @@ export function FreeInputBar({ mode, model, ratio, resolution, duration, seed, r
|
|||||||
<button type="button" className="fc-add fc-lib" title="人物素材库" onClick={() => onOpenLibrary()}>
|
<button type="button" className="fc-add fc-lib" title="人物素材库" onClick={() => onOpenLibrary()}>
|
||||||
<IconKitSvg name="users" size={18} />
|
<IconKitSvg name="users" size={18} />
|
||||||
</button>
|
</button>
|
||||||
|
<button type="button" className="fc-add fc-lib" title="引用平台素材(资产库 / 模特库 / 商品库)" onClick={() => onOpenPlatformLibrary()}>
|
||||||
|
<IconKitSvg name="library" size={18} />
|
||||||
|
</button>
|
||||||
{refs.map((item) => (
|
{refs.map((item) => (
|
||||||
<RefThumb key={item.key} item={item} onRemove={() => onRemoveRef(item.key)} />
|
<RefThumb key={item.key} item={item} onRemove={() => onRemoveRef(item.key)} />
|
||||||
))}
|
))}
|
||||||
|
|||||||
@@ -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<LibTab, string> = { assets: "资产库", models: "模特库", products: "商品库" };
|
||||||
|
const PAGE_SIZE = 24;
|
||||||
|
|
||||||
|
/** 引用前的审核判定。与后端 apps/assets/review.py 的 reference_review_state 同一套规则:
|
||||||
|
* 平台生成的免审,用户上传的必须审过。这里只为提前给个说法,真正拦人的是后端。 */
|
||||||
|
type RefState = "allowed" | "processing" | "failed" | "unsubmitted";
|
||||||
|
|
||||||
|
function refState(asset: Pick<Asset, "source" | "review_status">): 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<Exclude<RefState, "allowed">, { 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<LibTab>("assets");
|
||||||
|
const [queryInput, setQueryInput] = useState("");
|
||||||
|
const [query, setQuery] = useState("");
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
const [assets, setAssets] = useState<Asset[]>([]);
|
||||||
|
const [page, setPage] = useState(1);
|
||||||
|
const [hasMore, setHasMore] = useState(false);
|
||||||
|
const [models, setModels] = useState<ModelEntity[]>([]);
|
||||||
|
const [products, setProducts] = useState<Product[]>([]);
|
||||||
|
// 商品库是两层的:先选商品,再从它的图里挑一张
|
||||||
|
const [openProduct, setOpenProduct] = useState<Product | null>(null);
|
||||||
|
const [productAssets, setProductAssets] = useState<Asset[]>([]);
|
||||||
|
const [submittingReview, setSubmittingReview] = useState<string | null>(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 (
|
||||||
|
<div
|
||||||
|
key={asset.id}
|
||||||
|
className={`fc-lib-asset${state === "allowed" ? " pickable" : ""}`}
|
||||||
|
role="button"
|
||||||
|
tabIndex={0}
|
||||||
|
onClick={() => pickAsset(asset, labelHint)}
|
||||||
|
onKeyDown={(event) => { if (event.key === "Enter") pickAsset(asset, labelHint); }}
|
||||||
|
>
|
||||||
|
<div className="fc-lib-thumb">
|
||||||
|
{thumb && asset.asset_type === "image"
|
||||||
|
? <img src={thumb} alt={asset.name} />
|
||||||
|
: <span className="mono">{asset.asset_type === "audio" ? "♪" : asset.asset_type.toUpperCase()}</span>}
|
||||||
|
</div>
|
||||||
|
<div className="fc-lib-name" title={asset.display_name || asset.name}>{asset.display_name || asset.name}</div>
|
||||||
|
{pill
|
||||||
|
? <span className={`pill pill-l3 fc-lib-status ${pill.cls}`}><span className="dot" />{pill.label}</span>
|
||||||
|
: <span className="fc-lib-count mono">// {asset.source === "upload" ? "已过审" : "平台生成"}</span>}
|
||||||
|
{state === "unsubmitted" && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="btn btn-sm btn-ghost fc-lib-review-btn"
|
||||||
|
disabled={submittingReview === asset.id}
|
||||||
|
onClick={(event) => { event.stopPropagation(); void sendToReview(asset); }}
|
||||||
|
>
|
||||||
|
<ShieldCheck size={12} /> {submittingReview === asset.id ? "提交中…" : "送审"}
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
return createPortal(
|
||||||
|
<div className="modal-bg show" onClick={onClose}>
|
||||||
|
<div className="modal fc-lib-modal" onClick={(event) => event.stopPropagation()}>
|
||||||
|
<span className="corner-tr">+</span><span className="corner-bl">+</span>
|
||||||
|
<div className="modal-h">
|
||||||
|
<div className="ic-m"><Images size={16} /></div>
|
||||||
|
<div className="ti">
|
||||||
|
{openProduct ? (
|
||||||
|
<button type="button" className="fc-lib-back" onClick={() => { setOpenProduct(null); setProductAssets([]); }}>
|
||||||
|
<ChevronLeft size={14} /> {openProduct.title}
|
||||||
|
</button>
|
||||||
|
) : "引用平台素材"}
|
||||||
|
<span>// 资产库 · 模特库 · 商品库</span>
|
||||||
|
</div>
|
||||||
|
<button className="x modal-x" type="button" onClick={onClose} aria-label="关闭"><X size={14} /></button>
|
||||||
|
</div>
|
||||||
|
<div className="modal-b fc-lib-body">
|
||||||
|
{!openProduct && (
|
||||||
|
<div className="tabs fc-lib-tabs">
|
||||||
|
{(Object.keys(TAB_LABEL) as LibTab[]).map((key) => (
|
||||||
|
<div key={key} className={`tab${tab === key ? " active" : ""}`} role="button" tabIndex={0}
|
||||||
|
onClick={() => setTab(key)}
|
||||||
|
onKeyDown={(event) => { if (event.key === "Enter") setTab(key); }}>
|
||||||
|
{TAB_LABEL[key]}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<div className="fc-lib-toolbar">
|
||||||
|
{/* 商品下钻后列的是这一个商品的素材,搜索框在这层没有意义,收起来免得成死控件 */}
|
||||||
|
{!openProduct && (
|
||||||
|
<label className="fc-lib-search">
|
||||||
|
<Search size={13} />
|
||||||
|
<input
|
||||||
|
className="input"
|
||||||
|
placeholder={`搜索${TAB_LABEL[tab]}…`}
|
||||||
|
value={queryInput}
|
||||||
|
onChange={(event) => setQueryInput(event.target.value)}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
)}
|
||||||
|
<span className="mono fc-lib-hint">
|
||||||
|
{imageOnly ? "// 首尾帧只列图片素材" : "// 平台生成的直接可用,上传的要审核通过"}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{tab === "models" && !openProduct ? (
|
||||||
|
models.length === 0 && !loading ? (
|
||||||
|
<div className="empty-state show">
|
||||||
|
<span className="ic-empty"><User size={22} strokeWidth={1.5} /></span>
|
||||||
|
<h3>{emptyText}</h3>
|
||||||
|
<p>// 去模特库添加模特后可在这里引用形象图与三视图</p>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="fc-lib-assets">
|
||||||
|
{models.map((model) => {
|
||||||
|
const items = modelAssets(model);
|
||||||
|
return (
|
||||||
|
<div key={model.id} className="fc-lib-asset">
|
||||||
|
<div className="fc-lib-thumb">
|
||||||
|
{model.portrait ? <img src={model.portrait} alt={model.name} /> : <User size={20} />}
|
||||||
|
</div>
|
||||||
|
<div className="fc-lib-name" title={model.name}>{model.name}</div>
|
||||||
|
<div className="fc-lib-model-picks">
|
||||||
|
{items.length === 0
|
||||||
|
? <span className="fc-lib-count mono">// 还没有可引用的图</span>
|
||||||
|
: items.map((item) => (
|
||||||
|
<button key={item.id} type="button" className="btn btn-sm" onClick={() => pickModelAsset(model, item)}>
|
||||||
|
{item.tag}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
) : tab === "products" && !openProduct ? (
|
||||||
|
products.length === 0 && !loading ? (
|
||||||
|
<div className="empty-state show">
|
||||||
|
<span className="ic-empty"><Package size={22} strokeWidth={1.5} /></span>
|
||||||
|
<h3>{emptyText}</h3>
|
||||||
|
<p>// 去商品库建商品后可在这里引用它的素材</p>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="fc-lib-assets">
|
||||||
|
{products.map((product) => (
|
||||||
|
<div key={product.id} className="fc-lib-asset pickable" role="button" tabIndex={0}
|
||||||
|
onClick={() => void openProductDetail(product)}
|
||||||
|
onKeyDown={(event) => { if (event.key === "Enter") void openProductDetail(product); }}>
|
||||||
|
<div className="fc-lib-thumb">
|
||||||
|
{product.cover_preview_url ? <img src={product.cover_preview_url} alt={product.title} /> : <Package size={20} />}
|
||||||
|
</div>
|
||||||
|
<div className="fc-lib-name" title={product.title}>{product.title}</div>
|
||||||
|
<div className="fc-lib-count mono">// {product.category || "未分类"}</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
) : gridAssets.length === 0 && !loading ? (
|
||||||
|
<div className="empty-state show">
|
||||||
|
<span className="ic-empty"><Images size={22} strokeWidth={1.5} /></span>
|
||||||
|
<h3>{emptyText}</h3>
|
||||||
|
<p>// 平台生成的素材可直接引用,上传的要先审核通过</p>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<div className="fc-lib-assets">
|
||||||
|
{gridAssets.map((asset) => renderAssetCard(asset, openProduct ? `${openProduct.title}` : undefined))}
|
||||||
|
</div>
|
||||||
|
{!openProduct && tab === "assets" && hasMore && (
|
||||||
|
<div className="fc-lib-more">
|
||||||
|
<button type="button" className="btn btn-sm" disabled={loading} onClick={() => void loadAssets(page + 1)}>
|
||||||
|
{loading ? "加载中…" : "加载更多"}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>,
|
||||||
|
document.body
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -25,3 +25,141 @@
|
|||||||
color: var(--black-alpha-56, #8a8a8a);
|
color: var(--black-alpha-56, #8a8a8a);
|
||||||
padding: 4px 0 12px;
|
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; }
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,5 +1,65 @@
|
|||||||
|
import type { ReactNode } from "react";
|
||||||
|
import { IconKitSvg } from "./IconKitSvg";
|
||||||
import "./loading.css";
|
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 (
|
||||||
|
<div className={`system-loading system-loading--${variant}${state === "error" ? " is-error" : ""}`} role={state === "error" ? "alert" : "status"} aria-live="polite">
|
||||||
|
<div className="system-loading-card">
|
||||||
|
<span className="system-loading-corner corner-tl" aria-hidden="true">+</span>
|
||||||
|
<span className="system-loading-corner corner-tr" aria-hidden="true">+</span>
|
||||||
|
<span className="system-loading-corner corner-bl" aria-hidden="true">+</span>
|
||||||
|
<span className="system-loading-corner corner-br" aria-hidden="true">+</span>
|
||||||
|
|
||||||
|
<div className="system-loading-meta mono">
|
||||||
|
<span>[ AIRSHELF / SYSTEM ]</span>
|
||||||
|
<span>{state === "error" ? "[ CONNECTION ERROR ]" : "[ SYNCING ]"}</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="system-loading-main">
|
||||||
|
<div className="system-loading-icon" aria-hidden="true">
|
||||||
|
<IconKitSvg name={icon} size={20} />
|
||||||
|
{state === "loading" && <span className="system-loading-orbit" />}
|
||||||
|
</div>
|
||||||
|
<div className="system-loading-copy">
|
||||||
|
<h2>{title}</h2>
|
||||||
|
<p>{description}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{actions ? (
|
||||||
|
<div className="system-loading-actions">{actions}</div>
|
||||||
|
) : state === "loading" ? (
|
||||||
|
<div className="system-loading-progress" aria-hidden="true">
|
||||||
|
<span /><span /><span /><span /><span />
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
<div className="system-loading-foot mono">
|
||||||
|
<span>{reference ? `// REF · ${reference}` : "// DATA SYNC"}</span>
|
||||||
|
<span>{state === "error" ? "RETRY AVAILABLE" : "DATA HYDRATION"}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
// 列表数据在途时的骨架屏(grid 卡片 / 行),替代「为空」状态,让用户看到「加载中」而非「没数据」。
|
// 列表数据在途时的骨架屏(grid 卡片 / 行),替代「为空」状态,让用户看到「加载中」而非「没数据」。
|
||||||
export function SkeletonGrid({ count = 8 }: { count?: number }) {
|
export function SkeletonGrid({ count = 8 }: { count?: number }) {
|
||||||
return (
|
return (
|
||||||
|
|||||||
@@ -605,6 +605,11 @@
|
|||||||
color: var(--accent-crimson);
|
color: var(--accent-crimson);
|
||||||
border-color: var(--crimson-bd);
|
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 {
|
.fc-lib-status .dot {
|
||||||
width: 7px;
|
width: 7px;
|
||||||
height: 7px;
|
height: 7px;
|
||||||
@@ -678,6 +683,16 @@
|
|||||||
background: var(--crimson-bg);
|
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) {
|
@media (max-width: 720px) {
|
||||||
.fc-feed { grid-template-columns: 1fr; }
|
.fc-feed { grid-template-columns: 1fr; }
|
||||||
|
|||||||
@@ -139,6 +139,8 @@
|
|||||||
.chat-input-foot .spacer { flex: 1; }
|
.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 { 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: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 { 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:hover { background: var(--heat); border-color: var(--heat); }
|
||||||
.chat-send-btn:active { transform: scale(.95); }
|
.chat-send-btn:active { transform: scale(.95); }
|
||||||
@@ -313,6 +315,10 @@
|
|||||||
.prod-preview-history .h-thumb.adopted .badge { display: block; }
|
.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 { cursor: zoom-in; transition: border-color var(--t-base); }
|
||||||
.prod-preview-img.is-zoomable:hover { border-color: var(--heat-40); }
|
.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; }
|
.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; }
|
.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 { 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; }
|
.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; }
|
.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-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-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 { 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-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; }
|
.setup-card .setup-foot { display: flex; align-items: center; justify-content: flex-end; gap: 8px; margin-top: 4px; }
|
||||||
|
|
||||||
/* ── 行34 · 添加分镜:本地草稿可编辑卡片 ── */
|
/* ── 行34 · 添加分镜:本地草稿可编辑卡片 ── */
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import { Gauge, Wallet, X } from "lucide-react";
|
|||||||
import { adminApi } from "../../api";
|
import { adminApi } from "../../api";
|
||||||
import { Pager } from "../../components/pager";
|
import { Pager } from "../../components/pager";
|
||||||
import { IconKitSvg } from "../../components/IconKitSvg";
|
import { IconKitSvg } from "../../components/IconKitSvg";
|
||||||
|
import { SystemLoading } from "../../components/loading";
|
||||||
import type { AdminLedger, AdminQuotaPolicy, AdminTeam } from "../../types";
|
import type { AdminLedger, AdminQuotaPolicy, AdminTeam } from "../../types";
|
||||||
import { pts } from "../stage-config";
|
import { pts } from "../stage-config";
|
||||||
|
|
||||||
@@ -175,7 +176,7 @@ export function AdminLedgersPage({ notify }: { notify: Notify }) {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{loading ? (
|
{loading ? (
|
||||||
<div className="empty-state show"><div className="ic-empty"><IconKitSvg name="creditCard" size={24} /></div><h3>加载中…</h3><p>// fetching ledgers</p></div>
|
<SystemLoading title="正在加载计费数据" description="正在同步最新数据,请稍候。" icon="creditCard" />
|
||||||
) : ledgers.length === 0 ? (
|
) : ledgers.length === 0 ? (
|
||||||
<div className="empty-state show"><div className="ic-empty"><IconKitSvg name="creditCard" size={24} /></div><h3>暂无流水</h3><p>// no ledgers</p></div>
|
<div className="empty-state show"><div className="ic-empty"><IconKitSvg name="creditCard" size={24} /></div><h3>暂无流水</h3><p>// no ledgers</p></div>
|
||||||
) : (
|
) : (
|
||||||
@@ -331,7 +332,7 @@ export function AdminQuotaPage({ notify }: { notify: Notify }) {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{loading ? (
|
{loading ? (
|
||||||
<div className="empty-state show"><div className="ic-empty"><IconKitSvg name="gauge" size={24} /></div><h3>加载中…</h3><p>// fetching policies</p></div>
|
<SystemLoading title="正在加载额度配置" description="正在同步最新数据,请稍候。" icon="gauge" />
|
||||||
) : policies.length === 0 ? (
|
) : policies.length === 0 ? (
|
||||||
<div className="empty-state show"><div className="ic-empty"><IconKitSvg name="gauge" size={24} /></div><h3>暂无额度策略</h3><p>// 默认仅余额管控 · 点右上新建</p></div>
|
<div className="empty-state show"><div className="ic-empty"><IconKitSvg name="gauge" size={24} /></div><h3>暂无额度策略</h3><p>// 默认仅余额管控 · 点右上新建</p></div>
|
||||||
) : (
|
) : (
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import { useCallback, useEffect, useState } from "react";
|
|||||||
import { adminApi } from "../../api";
|
import { adminApi } from "../../api";
|
||||||
import { Pager } from "../../components/pager";
|
import { Pager } from "../../components/pager";
|
||||||
import { IconKitSvg } from "../../components/IconKitSvg";
|
import { IconKitSvg } from "../../components/IconKitSvg";
|
||||||
|
import { SystemLoading } from "../../components/loading";
|
||||||
import type { AdminIntegrity, AdminProjectRow } from "../../types";
|
import type { AdminIntegrity, AdminProjectRow } from "../../types";
|
||||||
|
|
||||||
type Notify = (type: "success" | "error" | "info", text: string) => void;
|
type Notify = (type: "success" | "error" | "info", text: string) => void;
|
||||||
@@ -107,7 +108,7 @@ export function AdminGovernancePage({ notify }: { notify: Notify }) {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{loading ? (
|
{loading ? (
|
||||||
<div className="empty-state show"><div className="ic-empty"><IconKitSvg name="clapperboard" size={24} /></div><h3>加载中…</h3><p>// fetching projects</p></div>
|
<SystemLoading title="正在加载项目" description="正在同步最新数据,请稍候。" icon="clapperboard" />
|
||||||
) : projects.length === 0 ? (
|
) : projects.length === 0 ? (
|
||||||
<div className="empty-state show"><div className="ic-empty"><IconKitSvg name="clapperboard" size={24} /></div><h3>暂无项目</h3><p>// no projects</p></div>
|
<div className="empty-state show"><div className="ic-empty"><IconKitSvg name="clapperboard" size={24} /></div><h3>暂无项目</h3><p>// no projects</p></div>
|
||||||
) : (
|
) : (
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import { Ticket, X } from "lucide-react";
|
|||||||
import { adminApi } from "../../api";
|
import { adminApi } from "../../api";
|
||||||
import { Pager } from "../../components/pager";
|
import { Pager } from "../../components/pager";
|
||||||
import { IconKitSvg } from "../../components/IconKitSvg";
|
import { IconKitSvg } from "../../components/IconKitSvg";
|
||||||
|
import { SystemLoading } from "../../components/loading";
|
||||||
import type { Invitation } from "../../types";
|
import type { Invitation } from "../../types";
|
||||||
|
|
||||||
type Notify = (type: "success" | "error" | "info", text: string) => void;
|
type Notify = (type: "success" | "error" | "info", text: string) => void;
|
||||||
@@ -129,7 +130,7 @@ export function AdminInvitesPage({ notify }: { notify: Notify }) {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{loading ? (
|
{loading ? (
|
||||||
<div className="empty-state show"><div className="ic-empty"><IconKitSvg name="ticket" size={24} /></div><h3>加载中…</h3><p>// fetching invitations</p></div>
|
<SystemLoading title="正在加载数据" description="正在同步最新数据,请稍候。" icon="ticket" />
|
||||||
) : invites.length === 0 ? (
|
) : invites.length === 0 ? (
|
||||||
<div className="empty-state show">
|
<div className="empty-state show">
|
||||||
<div className="ic-empty"><IconKitSvg name="ticket" size={24} /></div>
|
<div className="ic-empty"><IconKitSvg name="ticket" size={24} /></div>
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ import { useCallback, useEffect, useState } from "react";
|
|||||||
import { Server, X } from "lucide-react";
|
import { Server, X } from "lucide-react";
|
||||||
import { adminApi } from "../../api";
|
import { adminApi } from "../../api";
|
||||||
import { Pager } from "../../components/pager";
|
import { Pager } from "../../components/pager";
|
||||||
import { IconKitSvg } from "../../components/IconKitSvg";
|
import { SystemLoading } from "../../components/loading";
|
||||||
import type { AdminModel, AdminProvider } from "../../types";
|
import type { AdminModel, AdminProvider } from "../../types";
|
||||||
import { pts } from "../stage-config";
|
import { pts } from "../stage-config";
|
||||||
|
|
||||||
@@ -128,7 +128,7 @@ export function AdminModelsPage({ notify }: { notify: Notify }) {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{loading ? (
|
{loading ? (
|
||||||
<div className="empty-state show"><div className="ic-empty"><IconKitSvg name="server" size={24} /></div><h3>加载中…</h3><p>// fetching providers</p></div>
|
<SystemLoading title="正在加载配置" description="正在同步最新数据,请稍候。" icon="server" />
|
||||||
) : (
|
) : (
|
||||||
<>
|
<>
|
||||||
<div className="admin-detail-subhead">供应商</div>
|
<div className="admin-detail-subhead">供应商</div>
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { useCallback, useEffect, useState } from "react";
|
import { useCallback, useEffect, useState } from "react";
|
||||||
import { adminApi } from "../../api";
|
import { adminApi } from "../../api";
|
||||||
import { IconKitSvg } from "../../components/IconKitSvg";
|
import { SystemLoading } from "../../components/loading";
|
||||||
import type { AdminPromptTemplate } from "../../types";
|
import type { AdminPromptTemplate } from "../../types";
|
||||||
|
|
||||||
type Notify = (type: "success" | "error" | "info", text: string) => void;
|
type Notify = (type: "success" | "error" | "info", text: string) => void;
|
||||||
@@ -68,7 +68,7 @@ export function AdminPromptsPage({ notify }: { notify: Notify }) {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{loading ? (
|
{loading ? (
|
||||||
<div className="empty-state show"><div className="ic-empty"><IconKitSvg name="type" size={24} /></div><h3>加载中…</h3><p>// fetching prompt templates</p></div>
|
<SystemLoading title="正在加载配置" description="正在同步最新数据,请稍候。" icon="type" />
|
||||||
) : (
|
) : (
|
||||||
<div className="pt-list">
|
<div className="pt-list">
|
||||||
{rows.map((r) => {
|
{rows.map((r) => {
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { useCallback, useEffect, useState } from "react";
|
import { useCallback, useEffect, useState } from "react";
|
||||||
import { X } from "lucide-react";
|
import { X } from "lucide-react";
|
||||||
import { adminApi } from "../../api";
|
import { adminApi } from "../../api";
|
||||||
import { IconKitSvg } from "../../components/IconKitSvg";
|
import { SystemLoading } from "../../components/loading";
|
||||||
import type { AdminQualityWord } from "../../types";
|
import type { AdminQualityWord } from "../../types";
|
||||||
|
|
||||||
type Notify = (type: "success" | "error" | "info", text: string) => void;
|
type Notify = (type: "success" | "error" | "info", text: string) => void;
|
||||||
@@ -84,7 +84,7 @@ export function AdminQualityPage({ notify }: { notify: Notify }) {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{loading ? (
|
{loading ? (
|
||||||
<div className="empty-state show"><div className="ic-empty"><IconKitSvg name="type" size={24} /></div><h3>加载中…</h3><p>// fetching quality words</p></div>
|
<SystemLoading title="正在加载配置" description="正在同步最新数据,请稍候。" icon="type" />
|
||||||
) : (
|
) : (
|
||||||
<div className="qw-stages">
|
<div className="qw-stages">
|
||||||
{STAGES.map((s) => {
|
{STAGES.map((s) => {
|
||||||
|
|||||||
@@ -3,6 +3,8 @@ import { RefreshCw } from "lucide-react";
|
|||||||
import { adminApi } from "../../api";
|
import { adminApi } from "../../api";
|
||||||
import { Pager } from "../../components/pager";
|
import { Pager } from "../../components/pager";
|
||||||
import { IconKitSvg } from "../../components/IconKitSvg";
|
import { IconKitSvg } from "../../components/IconKitSvg";
|
||||||
|
import { SystemLoading } from "../../components/loading";
|
||||||
|
import { MediaLightbox } from "../../components/overlays";
|
||||||
import type { AdminReviewAsset } from "../../types";
|
import type { AdminReviewAsset } from "../../types";
|
||||||
|
|
||||||
type Notify = (type: "success" | "error" | "info", text: string) => void;
|
type Notify = (type: "success" | "error" | "info", text: string) => void;
|
||||||
@@ -32,6 +34,7 @@ export function AdminReviewsPage({ notify }: { notify: Notify }) {
|
|||||||
const [tab, setTab] = useState("");
|
const [tab, setTab] = useState("");
|
||||||
const [selected, setSelected] = useState<Set<string>>(new Set());
|
const [selected, setSelected] = useState<Set<string>>(new Set());
|
||||||
const [busy, setBusy] = useState(false);
|
const [busy, setBusy] = useState(false);
|
||||||
|
const [preview, setPreview] = useState<{ src: string; name: string } | null>(null);
|
||||||
|
|
||||||
const load = useCallback(async () => {
|
const load = useCallback(async () => {
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
@@ -113,7 +116,7 @@ export function AdminReviewsPage({ notify }: { notify: Notify }) {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{loading ? (
|
{loading ? (
|
||||||
<div className="empty-state show"><div className="ic-empty"><IconKitSvg name="shield" size={24} /></div><h3>加载中…</h3><p>// fetching review queue</p></div>
|
<SystemLoading title="正在加载审核数据" description="正在同步最新数据,请稍候。" icon="shield" />
|
||||||
) : assets.length === 0 ? (
|
) : assets.length === 0 ? (
|
||||||
<div className="empty-state show"><div className="ic-empty"><IconKitSvg name="shield" size={24} /></div><h3>暂无资产</h3><p>// no person assets</p></div>
|
<div className="empty-state show"><div className="ic-empty"><IconKitSvg name="shield" size={24} /></div><h3>暂无资产</h3><p>// no person assets</p></div>
|
||||||
) : (
|
) : (
|
||||||
@@ -135,7 +138,17 @@ export function AdminReviewsPage({ notify }: { notify: Notify }) {
|
|||||||
<td className="col-check"><input type="checkbox" checked={selected.has(a.id)} onChange={() => toggleOne(a.id)} aria-label="选择" /></td>
|
<td className="col-check"><input type="checkbox" checked={selected.has(a.id)} onChange={() => toggleOne(a.id)} aria-label="选择" /></td>
|
||||||
<td>
|
<td>
|
||||||
{a.preview_url
|
{a.preview_url
|
||||||
? <img className="admin-thumb" src={a.preview_url} alt={a.name} loading="lazy" />
|
? (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="admin-thumb-btn"
|
||||||
|
title="点击放大"
|
||||||
|
aria-label={`放大预览 ${a.name || "资产"}`}
|
||||||
|
onClick={() => setPreview({ src: a.preview_url, name: a.name || "预览" })}
|
||||||
|
>
|
||||||
|
<img className="admin-thumb" src={a.preview_url} alt={a.name} loading="lazy" />
|
||||||
|
</button>
|
||||||
|
)
|
||||||
: <span className="admin-thumb admin-thumb-empty" />}
|
: <span className="admin-thumb admin-thumb-empty" />}
|
||||||
</td>
|
</td>
|
||||||
<td>{a.team_name || <span className="muted">—</span>}</td>
|
<td>{a.team_name || <span className="muted">—</span>}</td>
|
||||||
@@ -160,12 +173,14 @@ export function AdminReviewsPage({ notify }: { notify: Notify }) {
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
{selected.size > 0 && (
|
{selected.size > 0 && (
|
||||||
<div className="bulk-bar show">
|
<div className="admin-bulk-bar" role="toolbar" aria-label="批量送审">
|
||||||
<span className="bulk-count">已选 {selected.size} 个</span>
|
<span className="admin-bulk-count">已选 {selected.size} 个</span>
|
||||||
<button className="btn btn-sm" type="button" onClick={() => setSelected(new Set())}>取消</button>
|
<button className="btn btn-sm" type="button" onClick={() => setSelected(new Set())}>取消</button>
|
||||||
<button className="btn btn-sm btn-primary" type="button" disabled={busy} onClick={() => void submit([...selected])}>批量送审</button>
|
<button className="btn btn-sm btn-primary" type="button" disabled={busy} onClick={() => void submit([...selected])}>批量送审</button>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
<MediaLightbox open={!!preview} src={preview?.src || ""} kind="image" name={preview?.name} close={() => setPreview(null)} />
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import { Activity, X } from "lucide-react";
|
|||||||
import { adminApi } from "../../api";
|
import { adminApi } from "../../api";
|
||||||
import { Pager } from "../../components/pager";
|
import { Pager } from "../../components/pager";
|
||||||
import { IconKitSvg } from "../../components/IconKitSvg";
|
import { IconKitSvg } from "../../components/IconKitSvg";
|
||||||
|
import { SystemLoading } from "../../components/loading";
|
||||||
import type { AdminTask, AdminTaskDetail } from "../../types";
|
import type { AdminTask, AdminTaskDetail } from "../../types";
|
||||||
import { pts } from "../stage-config";
|
import { pts } from "../stage-config";
|
||||||
|
|
||||||
@@ -127,7 +128,7 @@ export function AdminTasksPage({ notify }: { notify: Notify }) {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{loading ? (
|
{loading ? (
|
||||||
<div className="empty-state show"><div className="ic-empty"><IconKitSvg name="activity" size={24} /></div><h3>加载中…</h3><p>// fetching tasks</p></div>
|
<SystemLoading title="正在加载任务" description="正在同步最新数据,请稍候。" icon="activity" />
|
||||||
) : tasks.length === 0 ? (
|
) : tasks.length === 0 ? (
|
||||||
<div className="empty-state show"><div className="ic-empty"><IconKitSvg name="activity" size={24} /></div><h3>暂无任务</h3><p>// no tasks</p></div>
|
<div className="empty-state show"><div className="ic-empty"><IconKitSvg name="activity" size={24} /></div><h3>暂无任务</h3><p>// no tasks</p></div>
|
||||||
) : (
|
) : (
|
||||||
@@ -174,7 +175,7 @@ export function AdminTasksPage({ notify }: { notify: Notify }) {
|
|||||||
</div>
|
</div>
|
||||||
<div className="drawer-b">
|
<div className="drawer-b">
|
||||||
{detailLoading || !detail ? (
|
{detailLoading || !detail ? (
|
||||||
<p className="admin-modal-desc">加载中…</p>
|
<SystemLoading variant="inline" title="正在加载详情" />
|
||||||
) : (
|
) : (
|
||||||
<>
|
<>
|
||||||
{(() => {
|
{(() => {
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import { Building, KeyRound, Percent, X } from "lucide-react";
|
|||||||
import { adminApi } from "../../api";
|
import { adminApi } from "../../api";
|
||||||
import { Pager } from "../../components/pager";
|
import { Pager } from "../../components/pager";
|
||||||
import { IconKitSvg } from "../../components/IconKitSvg";
|
import { IconKitSvg } from "../../components/IconKitSvg";
|
||||||
|
import { SystemLoading } from "../../components/loading";
|
||||||
import type { AdminTeam, AdminTeamDetail, AdminUser } from "../../types";
|
import type { AdminTeam, AdminTeamDetail, AdminUser } from "../../types";
|
||||||
import { pts } from "../stage-config";
|
import { pts } from "../stage-config";
|
||||||
|
|
||||||
@@ -128,7 +129,7 @@ export function AdminTeamsPage({ notify }: { notify: Notify }) {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{loading ? (
|
{loading ? (
|
||||||
<div className="empty-state show"><div className="ic-empty"><IconKitSvg name="building" size={24} /></div><h3>加载中…</h3><p>// fetching teams</p></div>
|
<SystemLoading title="正在加载团队数据" description="正在同步最新数据,请稍候。" icon="building" />
|
||||||
) : teams.length === 0 ? (
|
) : teams.length === 0 ? (
|
||||||
<div className="empty-state show"><div className="ic-empty"><IconKitSvg name="building" size={24} /></div><h3>暂无团队</h3><p>// no teams</p></div>
|
<div className="empty-state show"><div className="ic-empty"><IconKitSvg name="building" size={24} /></div><h3>暂无团队</h3><p>// no teams</p></div>
|
||||||
) : (
|
) : (
|
||||||
@@ -198,7 +199,7 @@ export function AdminTeamsPage({ notify }: { notify: Notify }) {
|
|||||||
</div>
|
</div>
|
||||||
<div className="modal-b">
|
<div className="modal-b">
|
||||||
{detailLoading || !detail ? (
|
{detailLoading || !detail ? (
|
||||||
<p className="admin-modal-desc">加载中…</p>
|
<SystemLoading variant="inline" title="正在加载详情" icon="building" />
|
||||||
) : (
|
) : (
|
||||||
<>
|
<>
|
||||||
<div className="admin-detail-meta">
|
<div className="admin-detail-meta">
|
||||||
@@ -311,7 +312,7 @@ export function AdminUsersPage({ notify }: { notify: Notify }) {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{loading ? (
|
{loading ? (
|
||||||
<div className="empty-state show"><div className="ic-empty"><IconKitSvg name="users" size={24} /></div><h3>加载中…</h3><p>// fetching users</p></div>
|
<SystemLoading title="正在加载用户数据" description="正在同步最新数据,请稍候。" icon="users" />
|
||||||
) : users.length === 0 ? (
|
) : users.length === 0 ? (
|
||||||
<div className="empty-state show"><div className="ic-empty"><IconKitSvg name="users" size={24} /></div><h3>暂无用户</h3><p>// no users</p></div>
|
<div className="empty-state show"><div className="ic-empty"><IconKitSvg name="users" size={24} /></div><h3>暂无用户</h3><p>// no users</p></div>
|
||||||
) : (
|
) : (
|
||||||
|
|||||||
@@ -31,7 +31,7 @@ import type { AITask, Asset, ImageConversation, ImageConversationTask, ModelConf
|
|||||||
import { api } from "../api";
|
import { api } from "../api";
|
||||||
import { imageModelPickerOptions } from "../model-display";
|
import { imageModelPickerOptions } from "../model-display";
|
||||||
import { ModelLibrary } from "../components/model-library";
|
import { ModelLibrary } from "../components/model-library";
|
||||||
import { SkeletonRows } from "../components/loading";
|
import { SkeletonRows, SystemLoading } from "../components/loading";
|
||||||
import { ConfirmModal, MediaLightbox } from "../components/overlays";
|
import { ConfirmModal, MediaLightbox } from "../components/overlays";
|
||||||
import { Pager } from "../components/pager";
|
import { Pager } from "../components/pager";
|
||||||
import { useViewMode } from "../components/use-view-mode";
|
import { useViewMode } from "../components/use-view-mode";
|
||||||
@@ -461,7 +461,7 @@ export function AssetFactoryPage({ navigate }: { navigate: (page: Page) => void
|
|||||||
</div>
|
</div>
|
||||||
<div className="modal-b">
|
<div className="modal-b">
|
||||||
{batchLoading ? (
|
{batchLoading ? (
|
||||||
<div className="task-empty"><div className="mono">// LOADING…</div></div>
|
<SystemLoading variant="inline" title="正在加载详情" icon="images" />
|
||||||
) : batchImgs.length === 0 ? (
|
) : batchImgs.length === 0 ? (
|
||||||
<div className="task-empty"><div className="mono">// NO IMAGE</div><div>这批没有可显示的图片</div></div>
|
<div className="task-empty"><div className="mono">// NO IMAGE</div><div>这批没有可显示的图片</div></div>
|
||||||
) : (
|
) : (
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
// 任务流(滚动加载)+ 底部输入条(全能参考/首尾帧 + @mention)+ 渐进轮询(10/30/60s 打后端
|
// 任务流(滚动加载)+ 底部输入条(全能参考/首尾帧 + @mention)+ 渐进轮询(10/30/60s 打后端
|
||||||
// poll 端点,web 进程内查火山,不依赖 worker)+ 平滑进度动画(sessionStorage 续)+ 全屏播放器。
|
// poll 端点,web 进程内查火山,不依赖 worker)+ 平滑进度动画(sessionStorage 续)+ 全屏播放器。
|
||||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
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 { api, ApiError } from "../api";
|
||||||
import type { FreeVideoRef, FreeVideoTask, ModelConfig } from "../types";
|
import type { FreeVideoRef, FreeVideoTask, ModelConfig } from "../types";
|
||||||
import {
|
import {
|
||||||
@@ -23,7 +23,9 @@ import { FreeInputBar } from "../components/free-create/input-bar";
|
|||||||
import { GenerationCard } from "../components/free-create/generation-card";
|
import { GenerationCard } from "../components/free-create/generation-card";
|
||||||
import { VideoDetailModal } from "../components/free-create/video-detail-modal";
|
import { VideoDetailModal } from "../components/free-create/video-detail-modal";
|
||||||
import { AssetLibraryModal } from "../components/free-create/asset-library-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 type { PromptInputHandle } from "../components/free-create/prompt-input";
|
||||||
|
import { SystemLoading } from "../components/loading";
|
||||||
import { ConfirmModal } from "../components/overlays";
|
import { ConfirmModal } from "../components/overlays";
|
||||||
|
|
||||||
const PAGE_SIZE = 20;
|
const PAGE_SIZE = 20;
|
||||||
@@ -96,6 +98,7 @@ export function FreeCreatePage({ modelConfigs, onNotify, onTaskSettled }: {
|
|||||||
// —— 弹窗 ——
|
// —— 弹窗 ——
|
||||||
const [detailId, setDetailId] = useState<string | null>(null);
|
const [detailId, setDetailId] = useState<string | null>(null);
|
||||||
const [libraryOpen, setLibraryOpen] = useState(false);
|
const [libraryOpen, setLibraryOpen] = useState(false);
|
||||||
|
const [platformOpen, setPlatformOpen] = useState(false);
|
||||||
const [libraryTargetRole, setLibraryTargetRole] = useState<"first_frame" | "last_frame" | null>(null);
|
const [libraryTargetRole, setLibraryTargetRole] = useState<"first_frame" | "last_frame" | null>(null);
|
||||||
const [deleteTarget, setDeleteTarget] = useState<FreeVideoTask | null>(null);
|
const [deleteTarget, setDeleteTarget] = useState<FreeVideoTask | null>(null);
|
||||||
|
|
||||||
@@ -468,6 +471,21 @@ export function FreeCreatePage({ modelConfigs, onNotify, onTaskSettled }: {
|
|||||||
setLibraryTargetRole(null);
|
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;首尾帧写入对应槽位
|
// 素材库选中 → 全能参考插 mention;首尾帧写入对应槽位
|
||||||
const handleLibraryPick = useCallback((ref: FreeVideoRef) => {
|
const handleLibraryPick = useCallback((ref: FreeVideoRef) => {
|
||||||
if (mode === "keyframe") {
|
if (mode === "keyframe") {
|
||||||
@@ -483,8 +501,7 @@ export function FreeCreatePage({ modelConfigs, onNotify, onTaskSettled }: {
|
|||||||
...ref,
|
...ref,
|
||||||
key: nextRefKey(),
|
key: nextRefKey(),
|
||||||
role: libraryTargetRole,
|
role: libraryTargetRole,
|
||||||
label: undefined,
|
label: undefined
|
||||||
source: "library"
|
|
||||||
};
|
};
|
||||||
setRefs((prev) => [...prev.filter((r) => r.role !== libraryTargetRole), local]);
|
setRefs((prev) => [...prev.filter((r) => r.role !== libraryTargetRole), local]);
|
||||||
setLibraryTargetRole(null);
|
setLibraryTargetRole(null);
|
||||||
@@ -517,6 +534,9 @@ export function FreeCreatePage({ modelConfigs, onNotify, onTaskSettled }: {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="actions">
|
<div className="actions">
|
||||||
|
<button type="button" className="btn" onClick={() => openPlatformLibrary()}>
|
||||||
|
<Images size={14} /> 引用平台素材
|
||||||
|
</button>
|
||||||
<button type="button" className="btn" onClick={() => openLibrary()}>
|
<button type="button" className="btn" onClick={() => openLibrary()}>
|
||||||
<Users size={14} /> 人物素材库
|
<Users size={14} /> 人物素材库
|
||||||
</button>
|
</button>
|
||||||
@@ -525,7 +545,7 @@ export function FreeCreatePage({ modelConfigs, onNotify, onTaskSettled }: {
|
|||||||
|
|
||||||
<div className="fc-feed-wrap">
|
<div className="fc-feed-wrap">
|
||||||
{loading ? (
|
{loading ? (
|
||||||
<div className="fc-loading mono">// 加载中…</div>
|
<SystemLoading title="正在加载作品" description="正在同步最新数据,请稍候。" icon="film" />
|
||||||
) : tasks.length === 0 ? (
|
) : tasks.length === 0 ? (
|
||||||
<div className="empty-state show fc-empty">
|
<div className="empty-state show fc-empty">
|
||||||
<span className="ic-empty"><Users size={22} strokeWidth={1.5} /></span>
|
<span className="ic-empty"><Users size={22} strokeWidth={1.5} /></span>
|
||||||
@@ -572,6 +592,7 @@ export function FreeCreatePage({ modelConfigs, onNotify, onTaskSettled }: {
|
|||||||
onDurationChange={setDuration}
|
onDurationChange={setDuration}
|
||||||
onSeedChange={setSeed}
|
onSeedChange={setSeed}
|
||||||
onOpenLibrary={openLibrary}
|
onOpenLibrary={openLibrary}
|
||||||
|
onOpenPlatformLibrary={openPlatformLibrary}
|
||||||
onClear={clearInput}
|
onClear={clearInput}
|
||||||
onSend={() => void handleSend()}
|
onSend={() => void handleSend()}
|
||||||
/>
|
/>
|
||||||
@@ -593,6 +614,14 @@ export function FreeCreatePage({ modelConfigs, onNotify, onTaskSettled }: {
|
|||||||
|
|
||||||
<AssetLibraryModal open={libraryOpen} onClose={closeLibrary} onPick={handleLibraryPick} notify={notify} />
|
<AssetLibraryModal open={libraryOpen} onClose={closeLibrary} onPick={handleLibraryPick} notify={notify} />
|
||||||
|
|
||||||
|
<PlatformLibraryModal
|
||||||
|
open={platformOpen}
|
||||||
|
imageOnly={mode === "keyframe"}
|
||||||
|
onClose={closePlatformLibrary}
|
||||||
|
onPick={handleLibraryPick}
|
||||||
|
notify={notify}
|
||||||
|
/>
|
||||||
|
|
||||||
<ConfirmModal
|
<ConfirmModal
|
||||||
open={deleteTarget !== null}
|
open={deleteTarget !== null}
|
||||||
title="删除视频"
|
title="删除视频"
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import { RefreshCw, Upload, User, X } from "lucide-react";
|
|||||||
import { api } from "../api";
|
import { api } from "../api";
|
||||||
import { generationErrorText } from "../generation-error";
|
import { generationErrorText } from "../generation-error";
|
||||||
import type { ModelEntity } from "../types";
|
import type { ModelEntity } from "../types";
|
||||||
|
import { SystemLoading } from "../components/loading";
|
||||||
import { MediaLightbox, useBodyScrollLock, useOverlayTransition } from "../components/overlays";
|
import { MediaLightbox, useBodyScrollLock, useOverlayTransition } from "../components/overlays";
|
||||||
import "../models-page.css";
|
import "../models-page.css";
|
||||||
|
|
||||||
@@ -351,7 +352,7 @@ export function ModelsPage({ onNotify, onBillingChanged }: {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{loading ? (
|
{loading ? (
|
||||||
<div className="placeholder" style={{ minHeight: 200 }}><span className="ph-frame">// 加载中…</span></div>
|
<SystemLoading title="正在加载数据" description="正在同步最新数据,请稍候。" icon="model" />
|
||||||
) : items.length === 0 ? (
|
) : items.length === 0 ? (
|
||||||
<div className="placeholder models-empty">
|
<div className="placeholder models-empty">
|
||||||
<span className="ph-frame">// 还没有模特</span>
|
<span className="ph-frame">// 还没有模特</span>
|
||||||
|
|||||||
@@ -12,13 +12,32 @@ import { MediaLightbox, useBodyScrollLock } from "../components/overlays";
|
|||||||
import { ModelLibrary } from "../components/model-library";
|
import { ModelLibrary } from "../components/model-library";
|
||||||
import { ReviewBadge, type ReviewStatus } from "../components/review-badge";
|
import { ReviewBadge, type ReviewStatus } from "../components/review-badge";
|
||||||
import { IconKitSvg } from "../components/IconKitSvg";
|
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 圆角)
|
// 真实资产缩略图注入:与全站一致用 --mock-media-url(.placeholder.has-mock-media 负责 cover 裁切 + 8px 圆角)
|
||||||
const mediaStyle = (url: string): CSSProperties => ({ ["--mock-media-url"]: `url(${url})` } as CSSProperties);
|
const mediaStyle = (url: string): CSSProperties => ({ ["--mock-media-url"]: `url(${url})` } as CSSProperties);
|
||||||
// 基础资产组 kind → 中文区块名(期3:视频项目资产趴「人物」→「角色」,可引用模特库的可复用形象)
|
// 基础资产组 kind → 中文区块名(期3:视频项目资产趴「人物」→「角色」,可引用模特库的可复用形象)
|
||||||
const KIND_LABEL: Record<string, string> = { product: "商品", person: "角色", scene: "场景" };
|
const KIND_LABEL: Record<string, string> = { product: "商品", person: "角色", scene: "场景" };
|
||||||
// 脚本来源 → 「来源」brief pill 文案
|
// 脚本来源 → 「来源」brief pill 文案
|
||||||
const SOURCE_LABEL: Record<string, string> = { ai: "AI 全生", theme: "一句话主题", manual: "自带脚本" };
|
// 入口收敛为「脚本辅助生成 / 上传脚本」两种。theme 键保留:历史稿的 source 仍可能是旧的「一句话主题」。
|
||||||
|
const SOURCE_LABEL: Record<string, string> = { ai: "脚本辅助生成", theme: "脚本辅助生成", manual: "上传脚本", video: "上传视频提炼" };
|
||||||
// 旁白配音音色预设(语音合成经典版试用包实测可用,与后端 VOICEOVER_VOICES 对齐)
|
// 旁白配音音色预设(语音合成经典版试用包实测可用,与后端 VOICEOVER_VOICES 对齐)
|
||||||
const VO_VOICES = [
|
const VO_VOICES = [
|
||||||
{ key: "BV700_streaming", label: "灿灿 · 活力女声" },
|
{ key: "BV700_streaming", label: "灿灿 · 活力女声" },
|
||||||
@@ -28,9 +47,8 @@ const VO_VOICES = [
|
|||||||
{ key: "BV102_streaming", label: "儒雅青年 · 解说男声" },
|
{ key: "BV102_streaming", label: "儒雅青年 · 解说男声" },
|
||||||
{ key: "BV002_streaming", label: "通用男声" },
|
{ key: "BV002_streaming", label: "通用男声" },
|
||||||
];
|
];
|
||||||
// 新建向导落进 metadata.wizard 的是选项 key,这里映射回中文(对齐 projects.tsx 的 WIZ_STYLES/WIZ_PERSONAS)
|
// 新建向导落进 metadata.wizard 的是选项 key,这里映射回中文(对齐 projects.tsx 的 WIZ_PERSONAS)
|
||||||
// 脚本风格预设(顺序对齐设计稿设定卡下拉:真实测评 / 痛点种草 / 小红书种草 / 开箱测评 / 对比展示)
|
// 一期的「风格」(真实测评/痛点种草/…)已被二期的「视频结构」取代,见 script-setup.ts
|
||||||
const WIZ_STYLE_LABEL: Record<string, string> = { real: "真实测评", pain: "痛点种草", xhs: "小红书种草", review: "开箱测评", compare: "对比展示" };
|
|
||||||
const WIZ_PERSONA_LABEL: Record<string, string> = { urban: "都市白领女性", bestie: "闺蜜种草", ceo: "总裁亲选", reviewer: "专业测评师", mom: "实用宝妈", genz: "学生党" };
|
const WIZ_PERSONA_LABEL: Record<string, string> = { urban: "都市白领女性", bestie: "闺蜜种草", ceo: "总裁亲选", reviewer: "专业测评师", mom: "实用宝妈", genz: "学生党" };
|
||||||
// 视频片段状态 pill 文案(色调走 statusPill:ok/info/err/neutral)
|
// 视频片段状态 pill 文案(色调走 statusPill:ok/info/err/neutral)
|
||||||
function statusLabel(status: string): string {
|
function statusLabel(status: string): string {
|
||||||
@@ -570,6 +588,9 @@ export function PipelinePage(props: {
|
|||||||
try { return new Set<string>(JSON.parse(localStorage.getItem(`airshelf:pending-del:${project.id}`) || "[]")); } catch { return new Set(); }
|
try { return new Set<string>(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));
|
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
|
// 对白 speaker(entity id)→ 角色名;null = 旁白。entities 在 ScriptVersion.metadata
|
||||||
const scriptEntities = ((currentScript?.metadata as { entities?: Array<{ id: string; name: string }> } | undefined)?.entities) ?? [];
|
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) : "旁白");
|
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
|
// 每次生成=一个 product group=一版三视图;预览态(triPreviewId)只切主图,采用态存 metadata.product_tri_group
|
||||||
const [triPanelOpen, setTriPanelOpen] = useState(false);
|
const [triPanelOpen, setTriPanelOpen] = useState(false);
|
||||||
const [triPreviewId, setTriPreviewId] = useState<string | null>(null);
|
const [triPreviewId, setTriPreviewId] = useState<string | null>(null);
|
||||||
// 通用「选已有素材替换」选图层。商品三视图(取 product_image、按商品过滤)与场景(取 scene、团队全量)共用一套。
|
// 通用「选已有素材替换」选图层。当前由场景卡使用(取 scene、团队全量);按商品过滤的 product_image 分支留给后续复用。
|
||||||
// 角色不走这里——角色有专门的模特选择器(含模特库/角色三视图工作台),对角色是对的。
|
// 商品三视图不走这里——它已改成卡片上的独立上传框 + AI 生成两条通道(见 uploadTriViewFromFile)。
|
||||||
// 旧 bug:场景卡「替换」误用了模特选择器 → 列的是角色数据。现在场景改走本选图层。
|
// 角色也不走这里——角色有专门的模特选择器(含模特库/角色三视图工作台),对角色是对的。
|
||||||
type AssetPick = {
|
type AssetPick = {
|
||||||
title: string;
|
title: string;
|
||||||
category: "product_image" | "scene";
|
category: "product_image" | "scene";
|
||||||
@@ -731,6 +752,8 @@ export function PipelinePage(props: {
|
|||||||
setAssetPickBusy(false);
|
setAssetPickBusy(false);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
const triFileRef = useRef<HTMLInputElement | null>(null);
|
||||||
|
const [triUploading, setTriUploading] = useState(false);
|
||||||
function jumpAssetSection(kind: "product" | "person" | "scene") {
|
function jumpAssetSection(kind: "product" | "person" | "scene") {
|
||||||
setAssetTab(kind);
|
setAssetTab(kind);
|
||||||
if (typeof document !== "undefined") {
|
if (typeof document !== "undefined") {
|
||||||
@@ -1150,7 +1173,7 @@ export function PipelinePage(props: {
|
|||||||
const timeline = project.timeline;
|
const timeline = project.timeline;
|
||||||
const tlTotalMs = (timeline?.duration_seconds || 0) * 1000;
|
const tlTotalMs = (timeline?.duration_seconds || 0) * 1000;
|
||||||
const tlClips = [...(timeline?.clips ?? [])].sort((a, b) => a.sort_order - b.sort_order);
|
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 ruler = buildRuler(tlRulerMs / 1000);
|
||||||
const subtitleTrack = (timeline?.subtitle_tracks ?? []).find((t) => t.enabled) || (timeline?.subtitle_tracks ?? [])[0] || null;
|
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);
|
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 [chatText, setChatText] = useState("");
|
||||||
// 媒体预览灯箱(视频片段播放 / 故事板分镜放大)
|
// 媒体预览灯箱(视频片段播放 / 故事板分镜放大)
|
||||||
const [preview, setPreview] = useState<{ src: string; kind: "image" | "video"; name: string } | null>(null);
|
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<Array<{ name: string; chars: number }>>([]);
|
const [chatAttachments, setChatAttachments] = useState<Array<{ name: string; chars: number }>>([]);
|
||||||
// ── Stage 1 · 生成前「来源 & 风格 & 人物」设定(行28/行30):向导那边已删,这里补上 ──
|
// ── Stage 1 · 生成前「表现形式 × 视频结构 × 人物 × 时长」设定(1.5–1.9):向导那边已删,这里补上 ──
|
||||||
// setupOpen:三选项之一被选中后,展示风格/人物下拉 + 确认/重新推荐;确认后才真正发起生成。
|
// setupOpen:两个入口之一被选中后,展示四栏选择 + 确认/重新推荐;确认后才真正发起生成。
|
||||||
const SETUP_STYLE_KEYS = Object.keys(WIZ_STYLE_LABEL);
|
|
||||||
const SETUP_PERSONA_KEYS = Object.keys(WIZ_PERSONA_LABEL);
|
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 [setupOpen, setSetupOpen] = useState(false);
|
||||||
const [setupSource, setSetupSource] = useState<"ai" | "theme" | "manual">("ai");
|
const [setupSource, setSetupSource] = useState<"ai" | "manual" | "video">("ai");
|
||||||
const [setupStyle, setSetupStyle] = useState<string>(project.metadata?.wizard?.script_style || SETUP_STYLE_KEYS[0] || "pain");
|
const [setupFormat, setSetupFormat] = useState<PresentationFormat>(
|
||||||
const [setupPersona, setSetupPersona] = useState<string>(project.metadata?.wizard?.persona || SETUP_PERSONA_KEYS[0] || "urban");
|
(wizard?.presentation_format as PresentationFormat) || recommended.format
|
||||||
// 向导选的成片时长档("0-30"→30 秒)传给脚本 agent —— 脚本按它定镜数,别再写死 60(否则选 30s 也按 60s 出)
|
);
|
||||||
const wizardTotalDuration = (() => {
|
const [setupStructure, setSetupStructure] = useState<VideoStructure>(
|
||||||
const raw = project.metadata?.wizard?.duration;
|
(wizard?.video_structure as VideoStructure) || recommended.structure
|
||||||
const m = /(\d+)\s*$/.exec(typeof raw === "string" ? raw : "");
|
);
|
||||||
return m ? Number(m[1]) : 60;
|
const [setupPersona, setSetupPersona] = useState<string>(wizard?.persona || SETUP_PERSONA_KEYS[0] || "urban");
|
||||||
})();
|
const [setupDuration, setSetupDuration] = useState<number>(() => {
|
||||||
|
if (typeof wizard?.total_duration === "number") return clampDuration(wizard.total_duration);
|
||||||
|
// 兼容一期向导存的字符串档位("0-30" → 30)
|
||||||
|
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<HTMLTextAreaElement | null>(null);
|
const chatTextareaRef = useRef<HTMLTextAreaElement | null>(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<HTMLInputElement | null>(null);
|
const chatFileRef = useRef<HTMLInputElement | null>(null);
|
||||||
|
const [scriptFileBusy, setScriptFileBusy] = useState(false); // 脚本文件读取中(docx 走后端,有网络往返)
|
||||||
|
// 1.11 上传视频提炼:抽帧 + 读帧要 30~60 秒,比读 docx 慢一个量级,单独一个 busy 位并在聊天区留一条进度消息
|
||||||
|
const videoFileRef = useRef<HTMLInputElement | null>(null);
|
||||||
|
const [videoDigestBusy, setVideoDigestBusy] = useState(false);
|
||||||
const chatBodyRef = useRef<HTMLDivElement | null>(null);
|
const chatBodyRef = useRef<HTMLDivElement | null>(null);
|
||||||
// 对话记录(本地会话态):生成动作可追溯,不再是「点了按钮、对话区永远空着」
|
// 对话记录(本地会话态):生成动作可追溯,不再是「点了按钮、对话区永远空着」
|
||||||
// kind=progress:进度提示流(行33),steps 逐条滚动出现,done 后折叠成一行结果
|
// kind=progress:进度提示流(行33),steps 逐条滚动出现,done 后折叠成一行结果
|
||||||
@@ -1432,8 +1503,12 @@ export function PipelinePage(props: {
|
|||||||
model_config_id: activeScriptModelId || undefined,
|
model_config_id: activeScriptModelId || undefined,
|
||||||
base_version_id: baseVersionId,
|
base_version_id: baseVersionId,
|
||||||
aspect_ratio: "9:16",
|
aspect_ratio: "9:16",
|
||||||
total_duration: wizardTotalDuration,
|
// 设定卡的三个参数一路传到后端:后端据此挑套路提示词 + 按节奏切镜(不再每镜写死 15 秒)
|
||||||
target_index: targetIndex
|
total_duration: setupDuration,
|
||||||
|
presentation_format: setupFormat,
|
||||||
|
video_structure: setupStructure,
|
||||||
|
target_index: targetIndex,
|
||||||
|
source: source === "manual" || source === "video" ? source : undefined
|
||||||
},
|
},
|
||||||
(evt) => {
|
(evt) => {
|
||||||
receivedEvent = true;
|
receivedEvent = true;
|
||||||
@@ -1517,22 +1592,42 @@ export function PipelinePage(props: {
|
|||||||
pushMsg("ai", summaryText || "镜头脚本已生成,左侧已刷新。可继续输入修改意见,或点「确认脚本」进入下一步。");
|
pushMsg("ai", summaryText || "镜头脚本已生成,左侧已刷新。可继续输入修改意见,或点「确认脚本」进入下一步。");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
// 行28/行30 · 确认设定后真正发起生成:把所选「风格 / 人物」并进提示词(后端从 prompt 推断)
|
// 1.5 · 确认设定后真正发起生成。表现形式/结构/时长走结构化参数(后端挑套路),
|
||||||
|
// 人物没有对应的后端字段,仍并进 prompt 让模型读。
|
||||||
async function runScriptWithSetup() {
|
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;
|
const personaLabel = WIZ_PERSONA_LABEL[setupPersona] || setupPersona;
|
||||||
// 行28 · 持久化所选风格/人物到 metadata.wizard。先 await 落库(设定卡仍开着、确定 disabled),
|
// 持久化到 metadata.wizard。先 await 落库(设定卡仍开着、确定 disabled),
|
||||||
// 存完再「关卡」一帧切换,不出现空窗 → 不闪回三个菜单。
|
// 存完再「关卡」一帧切换,不出现空窗 → 不闪回入口菜单。
|
||||||
await onSaveProjectMeta?.({ wizard: { ...(project.metadata?.wizard ?? {}), script_style: setupStyle, persona: setupPersona } });
|
await onSaveProjectMeta?.({
|
||||||
|
wizard: {
|
||||||
|
...(project.metadata?.wizard ?? {}),
|
||||||
|
presentation_format: setupFormat,
|
||||||
|
video_structure: setupStructure,
|
||||||
|
total_duration: setupDuration,
|
||||||
|
persona: setupPersona,
|
||||||
|
},
|
||||||
|
});
|
||||||
setSetupOpen(false);
|
setSetupOpen(false);
|
||||||
const sourceLabel = SOURCE_LABEL[setupSource] || "AI 全生";
|
const combo = `${formatLabel} · ${structureLabel} · ${setupDuration}s · ${personaLabel}`;
|
||||||
if (setupSource === "theme") {
|
if (setupSource === "video") {
|
||||||
// 确定 = 确认风格/人物;主题交给模型来问 → 用户输入后由 submitChat 的 theme 分支带风格/人物生成
|
const base = chatText.trim();
|
||||||
setChatMode("theme");
|
if (!base) {
|
||||||
pushMsg("ai", `好,${styleLabel} · ${personaLabel} 定了。用一句话说说这条视频的主题(5-30字),例如「熬夜党的早八续命面膜」,发我就生成。`);
|
|
||||||
chatTextareaRef.current?.focus();
|
chatTextareaRef.current?.focus();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
setChatText("");
|
||||||
|
// 上传视频提炼:参考的是**结构与节奏**,画面里的商品是别人的,必须换成用户自己的商品重写
|
||||||
|
await runScriptGeneration(
|
||||||
|
`${base}\n以上是我拆解一条参考视频得到的分镜稿。请照搬它的叙事结构、镜头节奏和每镜的作用顺序,`
|
||||||
|
+ `把内容整体换成我自己的商品重写一遍;参考视频里出现的商品、品牌、人物一律不要保留。`
|
||||||
|
+ `原稿里标注「看不清 / 缺失 / 存疑」的地方,由你按我的商品补全。目标人群:${personaLabel}`,
|
||||||
|
`上传视频提炼 · ${combo}`,
|
||||||
|
"video",
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
if (setupSource === "manual") {
|
if (setupSource === "manual") {
|
||||||
const base = chatText.trim();
|
const base = chatText.trim();
|
||||||
if (!base) {
|
if (!base) {
|
||||||
@@ -1540,10 +1635,21 @@ export function PipelinePage(props: {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
setChatText("");
|
setChatText("");
|
||||||
await runScriptGeneration(`${base ? `${base}\n` : ""}据此整理成镜头脚本。风格:${styleLabel},目标人群:${personaLabel}`, `自带脚本 · ${styleLabel} · ${personaLabel}`, "manual");
|
// 上传脚本:先识别原稿结构,再补齐钩子 / 卖点证明 / 转化,而不是照抄一遍
|
||||||
|
await runScriptGeneration(
|
||||||
|
`${base}\n以上是我已有的脚本。请先识别它的结构与叙述顺序,尽量保留原意与原有表达,再据此整理成镜头脚本;`
|
||||||
|
+ `其中开头钩子、卖点的证明方式、结尾转化引导如有缺失或偏弱,请补齐补强。目标人群:${personaLabel}`,
|
||||||
|
`上传脚本 · ${combo}`,
|
||||||
|
"manual",
|
||||||
|
);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
await runScriptGeneration(`AI 全生 · 风格:${styleLabel},目标人群:${personaLabel}。突出商品卖点,节奏紧凑,适合短视频投放`, `${sourceLabel}:${styleLabel} · ${personaLabel}`, "ai");
|
const sourceLabel = SOURCE_LABEL[setupSource] || "脚本辅助生成";
|
||||||
|
await runScriptGeneration(
|
||||||
|
`目标人群:${personaLabel}。突出商品卖点,节奏紧凑,适合短视频投放`,
|
||||||
|
`${sourceLabel}:${combo}`,
|
||||||
|
"ai",
|
||||||
|
);
|
||||||
}
|
}
|
||||||
// 流程步骤3 · 把待确认的标签增删拼成给 AI 的重写指令 + 用户消息标签
|
// 流程步骤3 · 把待确认的标签增删拼成给 AI 的重写指令 + 用户消息标签
|
||||||
function tagEditsInstruction() {
|
function tagEditsInstruction() {
|
||||||
@@ -1558,15 +1664,6 @@ export function PipelinePage(props: {
|
|||||||
const text = chatText.trim();
|
const text = chatText.trim();
|
||||||
const tagInstr = tagEditsInstruction();
|
const tagInstr = tagEditsInstruction();
|
||||||
if (!text && !tagInstr) return;
|
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 prompt = [tagInstr, text].filter(Boolean).join("\n");
|
||||||
const label = [pendingTagEdits.map(tagEditVerb).join(" · "), text].filter(Boolean).join(" · ");
|
const label = [pendingTagEdits.map(tagEditVerb).join(" · "), text].filter(Boolean).join(" · ");
|
||||||
setChatText("");
|
setChatText("");
|
||||||
@@ -1594,40 +1691,86 @@ export function PipelinePage(props: {
|
|||||||
setSetupOpen(false);
|
setSetupOpen(false);
|
||||||
setChatMsgs([]);
|
setChatMsgs([]);
|
||||||
}
|
}
|
||||||
// 行30 · 三选项指引:选了某种生成方式 → 打开「来源 & 风格 & 人物」设定卡(确认后再生成)
|
// 选了某种生成方式 → 打开「来源 & 风格 & 人物」设定卡(确认后再生成)
|
||||||
function openSetup(source: "ai" | "theme" | "manual") {
|
function openSetup(source: "ai" | "manual" | "video") {
|
||||||
setSetupSource(source);
|
setSetupSource(source);
|
||||||
setChatMode(source);
|
setChatMode(source);
|
||||||
setSetupOpen(true);
|
setSetupOpen(true);
|
||||||
// 一句话主题:开卡时不再 push 提示(否则 chatMsgs 非空,点返回就回不到三选项);
|
if (source === "manual" || source === "video") {
|
||||||
// 主题改成在「确定」(确认风格/人物)之后由模型来问,更像对话流。
|
|
||||||
if (source === "manual") {
|
|
||||||
setChatText("");
|
setChatText("");
|
||||||
setChatAttachments([]);
|
setChatAttachments([]);
|
||||||
|
// 上传脚本 / 上传视频:选中即拉起选文件(用户手势内触发,不会被浏览器拦);想改成粘贴的话直接在输入框打字即可
|
||||||
|
(source === "video" ? videoFileRef : chatFileRef).current?.click();
|
||||||
window.setTimeout(() => chatTextareaRef.current?.focus(), 0);
|
window.setTimeout(() => chatTextareaRef.current?.focus(), 0);
|
||||||
}
|
}
|
||||||
// 文件上传入口先保留,当前「自带脚本」改为粘贴文本。
|
|
||||||
// if (source === "manual") { pickScriptMode(); }
|
|
||||||
}
|
}
|
||||||
function pickScriptMode() {
|
// 1.11 上传视频提炼:后端抽帧读帧出分镜稿 → 填进输入框。
|
||||||
setChatMode("manual");
|
// 稿子**故意落在可编辑的输入框里而不是直接出脚本**:AI 拆解必然有错,用户先逐镜改完再交给脚本 agent。
|
||||||
chatFileRef.current?.click();
|
async function onPickVideoFile(event: ChangeEvent<HTMLInputElement>) {
|
||||||
}
|
|
||||||
function onPickScriptFile(event: ChangeEvent<HTMLInputElement>) {
|
|
||||||
const file = event.target.files?.[0];
|
const file = event.target.files?.[0];
|
||||||
event.target.value = "";
|
event.target.value = "";
|
||||||
if (!file) return;
|
if (!file) return;
|
||||||
const reader = new FileReader();
|
if (!/\.(mp4|mov|m4v|webm)$/i.test(file.name)) {
|
||||||
reader.onload = () => {
|
onNotify?.("error", "只支持 mp4 / mov / m4v / webm 四种视频格式");
|
||||||
const text = String(reader.result || "").trim();
|
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<HTMLInputElement>) {
|
||||||
|
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));
|
setChatText((prev) => (prev ? `${prev}\n${text}` : text));
|
||||||
setChatAttachments((list) => [...list, { name: file.name, chars: text.length }]);
|
setChatAttachments((list) => [...list, { name: file.name, chars: text.length }]);
|
||||||
setChatMode("manual");
|
setChatMode("manual");
|
||||||
pushMsg("user", `已上传脚本附件《${file.name}》(${text.length} 字)`);
|
pushMsg("user", `已上传脚本《${file.name}》(${text.length} 字)`);
|
||||||
pushMsg("ai", "已读入你的脚本。可在输入框补充修改意见,点发送据此生成镜头脚本。");
|
pushMsg("ai", "已读入你的脚本。确认风格与人物后点「确定」,我会保留你的原意,并把钩子、卖点证明、转化引导补齐。");
|
||||||
chatTextareaRef.current?.focus();
|
chatTextareaRef.current?.focus();
|
||||||
};
|
} catch (error) {
|
||||||
reader.readAsText(file);
|
onNotify?.("error", error instanceof Error && error.message ? error.message : "读取脚本文件失败,请重试");
|
||||||
|
} finally {
|
||||||
|
setScriptFileBusy(false);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
// Stage 1 · 脚本助手宽度拖拽(对齐设计稿:clamp [380, 680],写 --chat-w)
|
// Stage 1 · 脚本助手宽度拖拽(对齐设计稿:clamp [380, 680],写 --chat-w)
|
||||||
const scriptGridRef = useRef<HTMLDivElement | null>(null);
|
const scriptGridRef = useRef<HTMLDivElement | null>(null);
|
||||||
@@ -2504,6 +2647,24 @@ export function PipelinePage(props: {
|
|||||||
const productName = productRecord?.title || "透真补水面膜";
|
const productName = productRecord?.title || "透真补水面膜";
|
||||||
const productCover = productRecord?.cover_asset || productRecord?.images?.find((img) => img.is_primary)?.asset || productRecord?.images?.[0]?.asset || null;
|
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 出图、直接扣用户费,
|
// 进资产趴【不再自动生成基础资产】——原先这里会自动 genPersonPortrait/genBaseAsset 出图、直接扣用户费,
|
||||||
// 用户有"被掏口袋"感。改为:进资产趴盖半透明蒙版 + 三按钮(只提取 / 提取+生成角色场景 / 提取+全套),
|
// 用户有"被掏口袋"感。改为:进资产趴盖半透明蒙版 + 三按钮(只提取 / 提取+生成角色场景 / 提取+全套),
|
||||||
// 由用户点了才提取/生成(见下方 STAGE 2 的 extract gate 与 runExtract)。
|
// 由用户点了才提取/生成(见下方 STAGE 2 的 extract gate 与 runExtract)。
|
||||||
@@ -2632,10 +2793,23 @@ export function PipelinePage(props: {
|
|||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="script-brief-summary" aria-label="当前创作方向">
|
<div className="script-brief-summary" aria-label="当前创作方向">
|
||||||
{/* 真实创作方向:来源=已有脚本的 source(无脚本时跟随所选模式),风格/人物=新建向导里选的(metadata.wizard) */}
|
{/* 真实创作方向:来源=已有脚本的 source(无脚本时跟随所选模式),其余=设定卡确认时存进 metadata.wizard 的 */}
|
||||||
<span className="pill neutral script-brief-pill"><span className="k">来源</span><span className="v" id="brief-source">{currentScript ? (SOURCE_LABEL[currentScript.source || "ai"] || "AI 全生") : chatMode !== "ai" ? SOURCE_LABEL[chatMode] : "未选择"}</span></span>
|
<span className="pill neutral script-brief-pill"><span className="k">来源</span><span className="v" id="brief-source">{currentScript ? (SOURCE_LABEL[currentScript.source || "ai"] || "脚本辅助生成") : setupOpen ? SOURCE_LABEL[setupSource] : "未选择"}</span></span>
|
||||||
<span className="pill neutral script-brief-pill"><span className="k">风格</span><span className="v" id="brief-style">{(() => { const k = project.metadata?.wizard?.script_style; return k ? (WIZ_STYLE_LABEL[k] || k) : "待确认"; })()}</span></span>
|
<span className="pill neutral script-brief-pill"><span className="k">形式</span><span className="v" id="brief-format">{(() => {
|
||||||
<span className="pill neutral script-brief-pill"><span className="k">人物</span><span className="v" id="brief-persona">{(() => { const k = project.metadata?.wizard?.persona; return k ? (WIZ_PERSONA_LABEL[k] || k) : "待确认"; })()}</span></span>
|
const k = wizard?.presentation_format as PresentationFormat | undefined;
|
||||||
|
// 旧项目只有一期的「风格」,没有表现形式 —— 显示待确认,别硬套
|
||||||
|
return k && PRESENTATION_FORMATS[k] ? PRESENTATION_FORMATS[k] : "待确认";
|
||||||
|
})()}</span></span>
|
||||||
|
<span className="pill neutral script-brief-pill"><span className="k">结构</span><span className="v" id="brief-structure">{(() => {
|
||||||
|
const k = wizard?.video_structure as VideoStructure | undefined;
|
||||||
|
return k && VIDEO_STRUCTURES[k] ? VIDEO_STRUCTURES[k] : "待确认";
|
||||||
|
})()}</span></span>
|
||||||
|
<span className="pill neutral script-brief-pill"><span className="k">时长</span><span className="v" id="brief-duration">{(() => {
|
||||||
|
// 有脚本时按各镜真实秒数加总(镜可以不等长了);没脚本就显示设定卡里选的
|
||||||
|
const scripted = (currentScript?.segments || []).reduce((sum, s) => sum + (s.duration_seconds || 0), 0);
|
||||||
|
return `${scripted || setupDuration} 秒`;
|
||||||
|
})()}</span></span>
|
||||||
|
<span className="pill neutral script-brief-pill"><span className="k">人物</span><span className="v" id="brief-persona">{(() => { const k = wizard?.persona; return k ? (WIZ_PERSONA_LABEL[k] || k) : "待确认"; })()}</span></span>
|
||||||
</div>
|
</div>
|
||||||
{/* 行34/流程步骤3 · 人物 / 场景标签:AI 出稿自动提取;增删不立即生效,排进待确认队列,
|
{/* 行34/流程步骤3 · 人物 / 场景标签:AI 出稿自动提取;增删不立即生效,排进待确认队列,
|
||||||
点发送才整体重写。待删的灰删除线、待加的虚线高亮,均可在输入框上方胶囊撤销。 */}
|
点发送才整体重写。待删的灰删除线、待加的虚线高亮,均可在输入框上方胶囊撤销。 */}
|
||||||
@@ -2687,7 +2861,7 @@ export function PipelinePage(props: {
|
|||||||
let cum = 0;
|
let cum = 0;
|
||||||
return shots.map((shot, index) => {
|
return shots.map((shot, index) => {
|
||||||
const start = cum;
|
const start = cum;
|
||||||
cum += shot.duration_seconds || 15;
|
cum += shotSeconds(shot);
|
||||||
const narration = (shot.narration || "").trim();
|
const narration = (shot.narration || "").trim();
|
||||||
const visual = (shot.visual_prompt || "").trim();
|
const visual = (shot.visual_prompt || "").trim();
|
||||||
// 旁白/画面历史数据可能同文(结构化解析前生成),画面框置空避免重复编辑同一段
|
// 旁白/画面历史数据可能同文(结构化解析前生成),画面框置空避免重复编辑同一段
|
||||||
@@ -2812,18 +2986,26 @@ export function PipelinePage(props: {
|
|||||||
<div className="time">{msg.time}</div>
|
<div className="time">{msg.time}</div>
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
{/* 行28/行30 · 选定生成方式后的「来源 & 风格 & 人物」设定卡(确认后再生成) */}
|
{/* 1.5 · 选定生成方式后的「表现形式 × 视频结构 × 人物 × 时长」设定卡(确认后再生成) */}
|
||||||
{setupOpen && (
|
{setupOpen && (
|
||||||
<div className="msg ai">
|
<div className="msg ai">
|
||||||
<div className="bubble setup-card">
|
<div className="bubble setup-card">
|
||||||
<div className="setup-lead">{setupSource === "manual" ? "已选「自带脚本」。" : setupSource === "theme" ? "已选「一句话主题」。" : "我会根据商品信息直接生成第一版。"}先确认创作方向。</div>
|
<div className="setup-lead">{setupSource === "manual" ? "已选「上传脚本」。" : setupSource === "video" ? "已选「上传视频提炼」。" : "我会根据商品信息直接生成第一版。"}先确认创作方向。</div>
|
||||||
<label className="setup-field">
|
<label className="setup-field">
|
||||||
<span className="sf-k">风格</span>
|
<span className="sf-k">表现形式</span>
|
||||||
<select className="setup-select" value={setupStyle} onChange={(e) => setSetupStyle(e.target.value)}>
|
<select className="setup-select" value={setupFormat} onChange={(e) => pickFormat(e.target.value as PresentationFormat)}>
|
||||||
{SETUP_STYLE_KEYS.map((k) => <option key={k} value={k}>{WIZ_STYLE_LABEL[k]}</option>)}
|
{PRESENTATION_KEYS.map((k) => <option key={k} value={k}>{PRESENTATION_FORMATS[k]}</option>)}
|
||||||
</select>
|
</select>
|
||||||
</label>
|
</label>
|
||||||
<div className="setup-rec">根据商品信息推荐</div>
|
<div className="setup-rec">{PRESENTATION_HINT[setupFormat]}</div>
|
||||||
|
{/* 1.8 组合联动:短剧下拉里没有「测评验证」—— 演出来的实测没有可信度 */}
|
||||||
|
<label className="setup-field">
|
||||||
|
<span className="sf-k">视频结构</span>
|
||||||
|
<select className="setup-select" value={setupStructure} onChange={(e) => { setupTouched.current = true; setSetupStructure(e.target.value as VideoStructure); }}>
|
||||||
|
{structureOptions.map((k) => <option key={k} value={k}>{VIDEO_STRUCTURES[k]}</option>)}
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
<div className="setup-rec">{STRUCTURE_HINT[setupStructure]}</div>
|
||||||
<label className="setup-field">
|
<label className="setup-field">
|
||||||
<span className="sf-k">人物</span>
|
<span className="sf-k">人物</span>
|
||||||
<select className="setup-select" value={setupPersona} onChange={(e) => setSetupPersona(e.target.value)}>
|
<select className="setup-select" value={setupPersona} onChange={(e) => setSetupPersona(e.target.value)}>
|
||||||
@@ -2831,15 +3013,25 @@ export function PipelinePage(props: {
|
|||||||
</select>
|
</select>
|
||||||
</label>
|
</label>
|
||||||
<div className="setup-rec">根据商品目标人群推荐</div>
|
<div className="setup-rec">根据商品目标人群推荐</div>
|
||||||
|
{/* 1.9 时长自由化:5–60 秒、5 秒一档;每镜切多长由后端按表现形式的节奏决定 */}
|
||||||
|
<label className="setup-field">
|
||||||
|
<span className="sf-k">时长</span>
|
||||||
|
<select className="setup-select" value={setupDuration} onChange={(e) => { setupTouched.current = true; setSetupDuration(clampDuration(Number(e.target.value))); }}>
|
||||||
|
{DURATION_OPTIONS.map((s) => <option key={s} value={s}>{s} 秒</option>)}
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
<div className={`setup-rec${durationHint ? " warn" : ""}`}>{durationHint || `${PRESENTATION_FORMATS[setupFormat]} × ${VIDEO_STRUCTURES[setupStructure]}建议 ${durationSuggest} 秒左右`}</div>
|
||||||
<div className="setup-foot">
|
<div className="setup-foot">
|
||||||
{/* ← 返回:收起设定卡,回到「AI全生/一句话主题/自带脚本」三选项页 */}
|
{/* ← 返回:收起设定卡,回到「脚本辅助生成 / 上传脚本」入口页 */}
|
||||||
<button type="button" className="btn btn-ghost btn-sm" onClick={() => setSetupOpen(false)}>← 返回</button>
|
<button type="button" className="btn btn-ghost btn-sm" onClick={() => setSetupOpen(false)}>← 返回</button>
|
||||||
<button type="button" className="btn btn-ghost btn-sm" onClick={() => {
|
<button type="button" className="btn btn-ghost btn-sm" title={recommended.reason} onClick={() => {
|
||||||
// 重新推荐:随机换一组,模拟「再推荐」(纯前端,后端会从 prompt 推断)
|
// 重新推荐:回到按商品信息算出的那一组(不是随机换,随机换等于没推荐)
|
||||||
setSetupStyle(SETUP_STYLE_KEYS[Math.floor(Math.random() * SETUP_STYLE_KEYS.length)] || setupStyle);
|
setupTouched.current = false;
|
||||||
setSetupPersona(SETUP_PERSONA_KEYS[Math.floor(Math.random() * SETUP_PERSONA_KEYS.length)] || setupPersona);
|
setSetupFormat(recommended.format);
|
||||||
|
setSetupStructure(recommended.structure);
|
||||||
|
setSetupDuration(recommended.duration);
|
||||||
}}>重新推荐</button>
|
}}>重新推荐</button>
|
||||||
<button type="button" className="btn btn-primary btn-sm" disabled={loading || (setupSource === "manual" && !chatText.trim())} onClick={() => void runScriptWithSetup()}>确定</button>
|
<button type="button" className="btn btn-primary btn-sm" disabled={loading || scriptFileBusy || videoDigestBusy || ((setupSource === "manual" || setupSource === "video") && !chatText.trim())} onClick={() => void runScriptWithSetup()}>确定</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="time">{nowHm()}</div>
|
<div className="time">{nowHm()}</div>
|
||||||
@@ -2851,9 +3043,10 @@ export function PipelinePage(props: {
|
|||||||
<div className="ce-title">选择一种生成方式开始</div>
|
<div className="ce-title">选择一种生成方式开始</div>
|
||||||
<div className="ce-hint">// 三种,由「最省事」到「最保真原意」</div>
|
<div className="ce-hint">// 三种,由「最省事」到「最保真原意」</div>
|
||||||
<div className="chat-modes">
|
<div className="chat-modes">
|
||||||
<button className={`chat-mode${chatMode === "ai" ? " primary" : ""}`} type="button" data-mode="ai" disabled={loading} onClick={() => openSetup("ai")}><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round"><path d="M12 3l1.7 4.6L18 9l-4.3 1.4L12 15l-1.7-4.6L6 9l4.3-1.4L12 3z" /></svg>AI 全生</button>
|
<button className={`chat-mode${chatMode === "ai" ? " primary" : ""}`} type="button" data-mode="ai" disabled={loading} onClick={() => openSetup("ai")}><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round"><path d="M12 3l1.7 4.6L18 9l-4.3 1.4L12 15l-1.7-4.6L6 9l4.3-1.4L12 3z" /></svg>脚本辅助生成</button>
|
||||||
<button className={`chat-mode${chatMode === "theme" ? " primary" : ""}`} type="button" data-mode="theme" onClick={() => openSetup("theme")}><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round"><path d="M9 18h6" /><path d="M10 22h4" /><path d="M15 14a4.65 4.65 0 0 0 1.4-2.5A6 6 0 1 0 6 8c0 1 .23 2.23 1.5 3.5" /></svg>一句话主题</button>
|
<button className={`chat-mode${chatMode === "manual" ? " primary" : ""}`} type="button" data-mode="manual" disabled={loading || scriptFileBusy || videoDigestBusy} onClick={() => openSetup("manual")}><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round"><path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z" /><path d="M14 2v6h6" /></svg>上传脚本</button>
|
||||||
<button className={`chat-mode${chatMode === "manual" ? " primary" : ""}`} type="button" data-mode="manual" onClick={() => openSetup("manual")}><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round"><path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z" /><path d="M14 2v6h6" /></svg>自带脚本</button>
|
{/* 1.11 上传视频提炼:拆一条参考视频的分镜,改成自己的商品重拍 */}
|
||||||
|
<button className={`chat-mode${chatMode === "video" ? " primary" : ""}`} type="button" data-mode="video" disabled={loading || scriptFileBusy || videoDigestBusy} onClick={() => openSetup("video")}><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round"><path d="m22 8-6 4 6 4V8Z" /><rect x="2" y="6" width="14" height="12" rx="2" /></svg>上传视频提炼</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
@@ -2878,7 +3071,7 @@ export function PipelinePage(props: {
|
|||||||
</span>
|
</span>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
<textarea ref={chatTextareaRef} className="chat-input-area" id="chat-textarea" placeholder={chatMode === "theme" ? "用一句话描述主题,如:熬夜党的早八续命面膜 ·(Enter 发送 / Shift+Enter 换行)" : chatMode === "manual" ? "粘贴你的脚本,AI 将据此生成镜头脚本 ·(Enter 发送)" : "直接说怎么改,如:更像小红书种草 / 换成熬夜党 ·(Enter 发送)"} rows={2} value={chatText} onChange={(event) => setChatText(event.target.value)}
|
<textarea ref={chatTextareaRef} className="chat-input-area" id="chat-textarea" placeholder={chatMode === "manual" ? "上传 docx / txt,或直接把脚本粘进来 ·(Enter 发送)" : chatMode === "video" ? "拆解稿会填在这里,逐镜改完再点「确定」·(Enter 发送)" : "直接说怎么改,如:更像小红书种草 / 换成熬夜党 ·(Enter 发送)"} rows={2} value={chatText} onChange={(event) => setChatText(event.target.value)}
|
||||||
onKeyDown={(event) => {
|
onKeyDown={(event) => {
|
||||||
// 行31 · Enter 发送,Shift+Enter 换行(输入法组合期不触发)
|
// 行31 · Enter 发送,Shift+Enter 换行(输入法组合期不触发)
|
||||||
if (event.key === "Enter" && !event.shiftKey && !event.nativeEvent.isComposing) {
|
if (event.key === "Enter" && !event.shiftKey && !event.nativeEvent.isComposing) {
|
||||||
@@ -2886,15 +3079,20 @@ export function PipelinePage(props: {
|
|||||||
submitChat();
|
submitChat();
|
||||||
}
|
}
|
||||||
}}></textarea>
|
}}></textarea>
|
||||||
{/* 文件上传入口先保留,当前自带脚本改为粘贴文本。
|
<input ref={chatFileRef} type="file" accept=".docx,.txt" style={{ display: "none" }} onChange={(event) => void onPickScriptFile(event)} />
|
||||||
<input ref={chatFileRef} type="file" accept=".txt,.md,.text,text/plain" style={{ display: "none" }} onChange={onPickScriptFile} />
|
<input ref={videoFileRef} type="file" accept=".mp4,.mov,.m4v,.webm" style={{ display: "none" }} onChange={(event) => void onPickVideoFile(event)} />
|
||||||
*/}
|
|
||||||
<div className="chat-input-foot">
|
<div className="chat-input-foot">
|
||||||
{/* 文件上传按钮先保留,当前自带脚本改为粘贴文本。
|
<button className="chat-icon-btn" id="chat-upload-btn" type="button" title="上传脚本(docx / txt)" aria-label="上传脚本(docx / txt)" disabled={scriptFileBusy} onClick={() => chatFileRef.current?.click()}>
|
||||||
<button className="chat-icon-btn" id="chat-upload-btn" type="button" title="上传脚本附件" aria-label="上传脚本附件" onClick={() => chatFileRef.current?.click()}>
|
{scriptFileBusy
|
||||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round"><path d="M12 5v14M5 12h14" /></svg>
|
? <span className="spinner" aria-hidden="true"></span>
|
||||||
|
: <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round"><path d="M12 5v14M5 12h14" /></svg>}
|
||||||
|
</button>
|
||||||
|
{/* 1.11 · 聊天已经开起来之后仍能提炼参考视频(入口菜单只在空态出现) */}
|
||||||
|
<button className="chat-icon-btn" id="chat-video-btn" type="button" title="上传视频提炼(mp4 / mov)" aria-label="上传视频提炼(mp4 / mov)" disabled={videoDigestBusy} onClick={() => videoFileRef.current?.click()}>
|
||||||
|
{videoDigestBusy
|
||||||
|
? <span className="spinner" aria-hidden="true"></span>
|
||||||
|
: <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round"><path d="m22 8-6 4 6 4V8Z" /><rect x="2" y="6" width="14" height="12" rx="2" /></svg>}
|
||||||
</button>
|
</button>
|
||||||
*/}
|
|
||||||
{/* 模型选择小按钮(输入框下方,对齐 ChatGPT/Lovart)· 复用 restraint chip 下拉,向上展开 */}
|
{/* 模型选择小按钮(输入框下方,对齐 ChatGPT/Lovart)· 复用 restraint chip 下拉,向上展开 */}
|
||||||
{textModels && textModels.length > 0 ? (
|
{textModels && textModels.length > 0 ? (
|
||||||
<div className={`chip-wrap chat-model-pick${modelMenuOpen ? " open" : ""}`}>
|
<div className={`chip-wrap chat-model-pick${modelMenuOpen ? " open" : ""}`}>
|
||||||
@@ -3051,27 +3249,33 @@ export function PipelinePage(props: {
|
|||||||
<div className="prod-date">{(project.created_at || "").slice(0, 10)} 创建</div>
|
<div className="prod-date">{(project.created_at || "").slice(0, 10)} 创建</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="prod-action" id="asset-prod-action">
|
<div className="prod-action" id="asset-prod-action">
|
||||||
{/* 与人物/场景卡页脚一致:hstack 左「使用已有素材」(ghost) + spacer + 右「AI 生成」(primary) */}
|
{/* 双通道:左「上传三视图」(ghost,用户自带图) + 右「AI 生成三视图」(primary,平台生成) */}
|
||||||
<div className="hstack">
|
<div className="hstack">
|
||||||
{/* 问题5(a):不想用平台生成 → 直接用已有素材(选商品图 / 上传本地图)当三视图 */}
|
<button className="btn btn-ghost btn-sm" type="button" data-stop disabled={triGenerating || triUploading} onClick={() => triFileRef.current?.click()}>{triUploading ? "上传中…" : "上传三视图"}</button>
|
||||||
<button className="btn btn-ghost btn-sm" type="button" data-stop disabled={triGenerating || assetPickBusy} onClick={() => setAssetPick({ title: "使用已有素材作三视图", category: "product_image", product: project.product, target: { kind: "product" }, uploadCategory: "tri_view", uploadName: `${productName || "商品"}·三视图` })}>替换</button>
|
|
||||||
<span className="spacer"></span>
|
<span className="spacer"></span>
|
||||||
<button className="btn btn-primary btn-sm" type="button" data-stop id="asset-prod-aigen-btn" disabled={triGenerating} onClick={runProductTri}>{triGenerating ? "生成中…" : (hasTriView ? "AI 重新生成三视图" : "AI 生成三视图")}</button>
|
<button className="btn btn-primary btn-sm" type="button" data-stop id="asset-prod-aigen-btn" disabled={triGenerating || triUploading} onClick={runProductTri}>{triGenerating ? "生成中…" : (hasTriView ? "AI 重新生成三视图" : "AI 生成三视图")}</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
<input ref={triFileRef} type="file" accept="image/*" hidden onChange={(e) => { const f = e.target.files?.[0]; if (f) void uploadTriViewFromFile(f); e.target.value = ""; }} />
|
||||||
{/* 行39 · 三视图预览面板(对齐原版 prod-preview):生成中转圈 → 主图(可放大)+ 重跑/采用 + 历史版本切换 */}
|
{/* 行39 · 三视图预览面板(对齐原版 prod-preview):生成中转圈 → 主图(可放大)+ 重跑/采用 + 历史版本切换 */}
|
||||||
<div className={`prod-preview${triPanelShow ? " show" : ""}`} id="asset-prod-preview">
|
<div className={`prod-preview${triPanelShow ? " show" : ""}`} id="asset-prod-preview">
|
||||||
<div className="prod-preview-h">// 三视图预览 · <span id="prod-preview-status">{triGenerating ? "生成中 · 约 12s" : previewTriAsset ? (previewTriAsset === adoptedTriAsset ? "已采用,不满意可重跑" : "预览中(未采用)") : "待生成"}</span></div>
|
<div className="prod-preview-h">// 三视图预览 · <span id="prod-preview-status">{triGenerating ? "生成中 · 约 12s" : previewTriAsset ? (previewTriAsset === adoptedTriAsset ? "已采用,不满意可重跑" : "预览中(未采用)") : "待生成"}</span></div>
|
||||||
{(() => {
|
{(() => {
|
||||||
const u = candUrl(productGroup, previewTriAsset);
|
const u = candUrl(productGroup, previewTriAsset);
|
||||||
if (triGenerating && !u) {
|
if ((triGenerating || triUploading) && !u) {
|
||||||
return <div className="placeholder prod-preview-img"><span className="asset-card-loading"><span className="asset-spinner" aria-hidden="true"></span><span className="mono">生成中</span></span></div>;
|
return <div className="placeholder prod-preview-img"><span className="asset-card-loading"><span className="asset-spinner" aria-hidden="true"></span><span className="mono">{triUploading ? "上传中" : "生成中"}</span></span></div>;
|
||||||
|
}
|
||||||
|
// 空态即上传框:点一下直接选本地图当三视图(不占用右侧 AI 生成那条路)
|
||||||
|
if (!u) {
|
||||||
|
return (
|
||||||
|
<div className="placeholder prod-preview-img prod-tri-drop" id="prod-preview-img" role="button" tabIndex={0} title="上传本地图片作三视图" onClick={() => triFileRef.current?.click()} onKeyDown={(e) => { if (e.key === "Enter" || e.key === " ") { e.preventDefault(); triFileRef.current?.click(); } }}>
|
||||||
|
<span className="ph-frame">+ 上传三视图</span>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
}
|
}
|
||||||
return (
|
return (
|
||||||
<div className={`placeholder prod-preview-img${u ? " has-mock-media is-zoomable" : ""}`} id="prod-preview-img" role={u ? "button" : undefined} tabIndex={u ? 0 : undefined} title={u ? "点击放大查看" : undefined} style={u ? mediaStyle(u) : undefined} onClick={u ? () => 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}>
|
<div className="placeholder prod-preview-img has-mock-media is-zoomable" id="prod-preview-img" role="button" tabIndex={0} title="点击放大查看" style={mediaStyle(u)} onClick={() => setPreview({ src: u, kind: "image", name: "三视图" })} onKeyDown={(e) => { if (e.key === "Enter" || e.key === " ") { e.preventDefault(); setPreview({ src: u, kind: "image", name: "三视图" }); } }} />
|
||||||
{!u && <span className="ph-frame">// 暂无三视图</span>}
|
|
||||||
</div>
|
|
||||||
);
|
);
|
||||||
})()}
|
})()}
|
||||||
{previewTriAsset && (
|
{previewTriAsset && (
|
||||||
@@ -3302,7 +3506,7 @@ export function PipelinePage(props: {
|
|||||||
{viewStage === 3 && (() => {
|
{viewStage === 3 && (() => {
|
||||||
// 每场时间区间(累加脚本镜时长 → 「0~5s」)
|
// 每场时间区间(累加脚本镜时长 → 「0~5s」)
|
||||||
let cum = 0;
|
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 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 shotBusy = (s: StoryboardShot) => sbGenerating || sbBusyShots.has(s.id) || ["queued", "running"].includes(s.status);
|
||||||
const cardCount = sbShots.length || sbExpectedShots;
|
const cardCount = sbShots.length || sbExpectedShots;
|
||||||
@@ -3471,6 +3675,15 @@ export function PipelinePage(props: {
|
|||||||
{viewStage === 4 && (() => {
|
{viewStage === 4 && (() => {
|
||||||
const pct = segments.length ? Math.round((segDone / segments.length) * 100) : 0;
|
const pct = segments.length ? Math.round((segDone / segments.length) * 100) : 0;
|
||||||
const anyStarted = segments.some((s) => ["running", "succeeded", "queued"].includes(s.status));
|
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
|
const statusText = !segments.length
|
||||||
? "暂无片段"
|
? "暂无片段"
|
||||||
: segDone === segments.length
|
: segDone === segments.length
|
||||||
@@ -3483,7 +3696,7 @@ export function PipelinePage(props: {
|
|||||||
<div className="queue-bar">
|
<div className="queue-bar">
|
||||||
<div>
|
<div>
|
||||||
<div style={{ fontSize: "14px", fontWeight: 600 }}>视频生成 · {segDone} / {segments.length} 完成</div>
|
<div style={{ fontSize: "14px", fontWeight: 600 }}>视频生成 · {segDone} / {segments.length} 完成</div>
|
||||||
<div className="muted-2 mono" style={{ fontSize: "12px", marginTop: "3px", letterSpacing: ".02em" }}>// 每场 Seedance 约 15 秒 · {statusText}</div>
|
<div className="muted-2 mono" style={{ fontSize: "12px", marginTop: "3px", letterSpacing: ".02em" }}>// {paceText} · {statusText}</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="bar-wrap"><span style={{ width: `${pct}%` }}></span></div>
|
<div className="bar-wrap"><span style={{ width: `${pct}%` }}></span></div>
|
||||||
<span className="muted mono" style={{ fontSize: "12px" }}>{pct}%</span>
|
<span className="muted mono" style={{ fontSize: "12px" }}>{pct}%</span>
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { useEffect, useMemo, useState } from "react";
|
import { useEffect, useMemo, useState } from "react";
|
||||||
import type { CSSProperties } from "react";
|
import type { CSSProperties } from "react";
|
||||||
import { api } from "../api";
|
import { api } from "../api";
|
||||||
|
import { SystemLoading } from "../components/loading";
|
||||||
import { ConfirmModal } from "../components/overlays";
|
import { ConfirmModal } from "../components/overlays";
|
||||||
import type { NavigateFn } from "./route-config";
|
import type { NavigateFn } from "./route-config";
|
||||||
import type { Asset, FreeVideoTask, ImageConversationTrash, ImageExceptionBatchTrash, ModelEntity, Product, Project } from "../types";
|
import type { Asset, FreeVideoTask, ImageConversationTrash, ImageExceptionBatchTrash, ModelEntity, Product, Project } from "../types";
|
||||||
@@ -326,7 +327,7 @@ export function TrashPage({ onRestore, onPurge, onRestoreProducts, onPurgeProduc
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{loading ? (
|
{loading ? (
|
||||||
<div className="placeholder" style={{ minHeight: 160 }}><span className="ph-frame">// 加载中...</span></div>
|
<SystemLoading title="正在加载数据" description="正在同步最新数据,请稍候。" icon="trash" />
|
||||||
) : isEmpty ? (
|
) : isEmpty ? (
|
||||||
<div className="placeholder trash-empty"><span className="ph-frame">// 垃圾桶是空的</span></div>
|
<div className="placeholder trash-empty"><span className="ph-frame">// 垃圾桶是空的</span></div>
|
||||||
) : (
|
) : (
|
||||||
|
|||||||
@@ -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<PresentationFormat, string> = {
|
||||||
|
oral: "口播",
|
||||||
|
drama: "短剧",
|
||||||
|
vlog: "Vlog",
|
||||||
|
};
|
||||||
|
|
||||||
|
export const VIDEO_STRUCTURES: Record<VideoStructure, string> = {
|
||||||
|
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<PresentationFormat, string> = {
|
||||||
|
oral: "一个人对镜头说,信息密度最高、最省制作",
|
||||||
|
drama: "有角色有冲突的微型剧,完播率最高",
|
||||||
|
vlog: "第一人称生活记录,广告感最低",
|
||||||
|
};
|
||||||
|
|
||||||
|
export const STRUCTURE_HINT: Record<VideoStructure, string> = {
|
||||||
|
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<PresentationFormat, number> = { oral: 30, drama: 45, vlog: 30 };
|
||||||
|
/** 各结构能压到的最短总时长 —— 低于它证据或氛围就不成立了。 */
|
||||||
|
const STRUCTURE_MIN_DURATION: Record<VideoStructure, number> = { 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,
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -510,7 +510,17 @@ export type Project = {
|
|||||||
storyboard_shots: StoryboardShot[];
|
storyboard_shots: StoryboardShot[];
|
||||||
timeline: Timeline | null;
|
timeline: Timeline | null;
|
||||||
metadata?: {
|
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[];
|
cast?: string[];
|
||||||
scenes?: string[];
|
scenes?: string[];
|
||||||
// 流程步骤3/4 · 脚本提取出的每个人物/场景的建议生图提示词(基础资产据此 seed 卡片)
|
// 流程步骤3/4 · 脚本提取出的每个人物/场景的建议生图提示词(基础资产据此 seed 卡片)
|
||||||
@@ -590,8 +600,10 @@ export type FreeVideoRef = {
|
|||||||
label?: string; // @mention 引用名
|
label?: string; // @mention 引用名
|
||||||
thumb_url?: string;
|
thumb_url?: string;
|
||||||
duration?: number; // 视频/音频时长(秒)
|
duration?: number; // 视频/音频时长(秒)
|
||||||
asset_id?: string; // 直传素材的 Asset id / 素材库 FreeAsset id
|
// asset_id 的含义随 source 变:upload/asset → 平台 Asset id;library → 人物素材库 FreeAsset id
|
||||||
source?: "upload" | "library" | "library_group";
|
asset_id?: string;
|
||||||
|
// asset = 从资产库/模特库/商品库挑的平台资产(模块4 · 4.1),后端按来源+审核态判能不能引用
|
||||||
|
source?: "upload" | "library" | "library_group" | "asset";
|
||||||
group_id?: string;
|
group_id?: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -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);
|
||||||
@@ -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 });
|
||||||
@@ -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);
|
||||||
@@ -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");
|
||||||
@@ -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);
|
||||||
@@ -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);
|
||||||
|
After Width: | Height: | Size: 60 KiB |
|
After Width: | Height: | Size: 77 KiB |
|
After Width: | Height: | Size: 110 KiB |
|
After Width: | Height: | Size: 483 KiB |
|
After Width: | Height: | Size: 100 KiB |
|
After Width: | Height: | Size: 100 KiB |
|
After Width: | Height: | Size: 103 KiB |
|
After Width: | Height: | Size: 65 KiB |
|
After Width: | Height: | Size: 116 KiB |
|
After Width: | Height: | Size: 161 KiB |
|
After Width: | Height: | Size: 183 KiB |
|
After Width: | Height: | Size: 474 KiB |
|
After Width: | Height: | Size: 193 KiB |
|
After Width: | Height: | Size: 73 KiB |
|
After Width: | Height: | Size: 12 KiB |
|
After Width: | Height: | Size: 32 KiB |
|
After Width: | Height: | Size: 136 KiB |
|
After Width: | Height: | Size: 50 KiB |