大量修改二期功能清单内容

This commit is contained in:
Azmat@qq.com
2026-08-17 18:26:42 +08:00
parent 36e91aab3c
commit d1ecb52125
76 changed files with 4222 additions and 294 deletions
+152
View File
@@ -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。与《二期需求整理总纲》配套,随分期与对齐结论更新。*
+51
View File
@@ -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),
),
]
+1
View File
@@ -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"
+301 -44
View File
@@ -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")
+142
View File
@@ -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)
+295
View File
@@ -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
+34 -3
View File
@@ -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。"""
+1 -1
View File
@@ -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
+8 -3
View File
@@ -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
+99 -13
View File
@@ -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` 取 **560 秒之间的 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 | | 总时长 | **560 秒,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)与时长560 秒,未指定默认 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`**560 之间 5 的倍数**
- [ ] **每个 `segment.duration` 是 415 之间的整数**(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 标题符号(`#`)和代码块围栏。
+22 -38
View File
@@ -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) {
+25 -5
View File
@@ -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; }
+28 -3
View File
@@ -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
);
}
+138
View File
@@ -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; }
}
+60
View File
@@ -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 (
+15
View File
@@ -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; }
+12 -3
View File
@@ -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>
) : ( ) : (
+2 -2
View File
@@ -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>
) : ( ) : (
+33 -4
View File
@@ -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="删除视频"
+2 -1
View File
@@ -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>
+315 -102
View File
@@ -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>
+2 -1
View File
@@ -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>
) : ( ) : (
+133
View File
@@ -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,
};
}
+15 -3
View File
@@ -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;
}; };
+72
View File
@@ -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);
+57
View File
@@ -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 });
+65
View File
@@ -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);
+65
View File
@@ -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");
+77
View File
@@ -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);
+71
View File
@@ -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);
Binary file not shown.

After

Width:  |  Height:  |  Size: 60 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 77 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 110 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 483 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 100 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 100 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 103 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 65 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 116 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 161 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 183 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 474 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 193 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 73 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 32 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 136 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 50 KiB