feat(core): AI 生成 Agent 化 — 多模型流式脚本 agent + 可插拔 Provider + gpt-image-2 参考图 + 模特库
- 后端·可插拔 Provider 层:通用 OpenAICompatibleProvider(tokenssr 等中转站,base_url+api_key,零改代码换站)+ ModelProvider.api_key - 后端·脚本 agent:结构化 ScriptDraft 契约 + 加载电商 skill + 出稿/改稿一体对话 agent(3模式/多模型)+ 流式 SSE 端点(DRF SSE renderer) - 后端·图像:gpt-image-2 参考图出图 + 故事板 @图1@图2@图3 多锚点合成(锁脸锁商品);Seedance 打开 generate_audio - 后端·模特库:gpt-image-2 生成器(9:16氛围图→16:9白底三视图)+ seed_demo_models 管理命令 - DB·迁移:tokenssr 中转站 + 多模型 seed(豆包/GPT-5.5/Gemini + gpt-image-2);ScriptSegment 结构化字段 - 前端·脚本趴:接真 SSE(工具卡 + 思考流)+ 模型下拉 + 3模式 + 改稿;agentScriptStream - skills/ecommerce-video-script 电商脚本技能(运行时依赖) Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 4.8
parent
a0ffb6fc8e
commit
6464001f84
@@ -0,0 +1,261 @@
|
||||
# AirShelf · AI 生成 Agent 化落地方案
|
||||
|
||||
> 目标:把【脚本生成】升级为「多模型可选 + 出稿/改稿一体」的对话式 agent,并打通后续图片/故事板/视频 SOP。
|
||||
> 用户定位:不懂视频制作的电商小白,点按钮就出"能吸睛、能转化"的带货短视频,提示词全替用户包好。
|
||||
> 本文整合 2026-06-16 多轮讨论结论,作为开工 SSoT。
|
||||
|
||||
---
|
||||
|
||||
## 0. 一句话现状
|
||||
|
||||
5 阶段流水线(商品 → 脚本 → 图片 → 故事板 → 视频)页面已搭好,生成链路能跑,但:**脚本是一次性散文+正则解析、无 agent、无多模型;图片"平台套图"是装饰;参考图生成断线;Seedance 音频被关。**
|
||||
|
||||
---
|
||||
|
||||
## 1. 现状核查(已验证,带 file:line)
|
||||
|
||||
### 1.0 模型与中转站(最终方案,均已实测 200)
|
||||
**主力中转站 = `tokenssr`(`https://king.tokenssr.com/v1`,一把 key 通吃 90 模型,见 .env `TOKENSSR_API_KEY`)。**
|
||||
|
||||
| 用途 | 模型 | 端点/调法 |
|
||||
|---|---|---|
|
||||
| 脚本·豆包 | doubao-seed-2.0-pro | 火山 ARK chat(本地直连) |
|
||||
| 脚本·GPT | `gpt-5.5` | tokenssr `chat/completions` |
|
||||
| 脚本·Gemini | `gemini-3.1-pro-preview` / `gemini-3.5-flash` | tokenssr `chat/completions` |
|
||||
| 纯文生图 | `gpt-image-2` | tokenssr `images/generations` |
|
||||
| **参考图/编辑** ⭐ | **`gpt-image-2`** | **tokenssr `images/edits`(multipart `image[]`,1~多张参考图,返回 b64)** |
|
||||
| 视频 | seedance-2.0 | 火山 ARK(本地直连) |
|
||||
|
||||
- 多图参考(故事板 @图1@图2@图3)实测 200;`gemini-3-pro-image-preview`(chat 多模态)留作图像备选/风格选择。
|
||||
- YunQi 那家分组割裂、gpt-image-2 渠道残废 → 已弃用,仅留历史参考。
|
||||
- token 用量正常返回,对齐"按实际 token 扣费"。
|
||||
|
||||
### 1.1 模型与 fallback
|
||||
| 类型 | 实际模型 | Provider | Fallback |
|
||||
|---|---|---|---|
|
||||
| 脚本 text | Doubao-Seed-2.0-Pro | 火山 ARK(写死) | ❌ 无 |
|
||||
| 图片 image | **GPT-Image-2 / YunQi 网关**(Seedream 被 disabled) | YunQi | ❌ 无 |
|
||||
| 视频 video | Seedance-2.0 | 火山 ARK(写死) | ⚠️ 仅"丢参考图退纯文生视频" |
|
||||
|
||||
- 默认选模型 = 该能力下最早创建的 active 行 [services.py:33-39](core/backend/apps/ai/services.py#L33-L39)
|
||||
- 生图按 provider 分流(yunqi/火山) [services.py:42-46](core/backend/apps/ai/services.py#L42-L46)
|
||||
- YunqiProvider 继承火山,`chat_completion` 现成 → **文本接多模型很便宜** [yunqi.py](core/backend/apps/ai/providers/yunqi.py)
|
||||
|
||||
### 1.2 脚本生成(无 agent)
|
||||
- 系统提示词:4×15s、强制 `镜头N/旁白/画面` 格式 [services.py:53-77](core/backend/apps/ai/services.py#L53-L77)
|
||||
- **正则**抠字段 [services.py:81](core/backend/apps/ai/services.py#L81)
|
||||
- **第二次** LLM 调用抽 人物/场景 JSON [services.py:116](core/backend/apps/ai/services.py#L116)
|
||||
- 全是一次性调用,无循环/无工具/无自检
|
||||
|
||||
### 1.3 图片(平台套图是空壳)
|
||||
- 三种模式提示词模板在前端 [ai-tools.tsx:387](core/frontend/src/routes/ai-tools.tsx#L387)
|
||||
- 平台卡/模特卡选择 `pickedIds` **只做高亮,不拼进 prompt、不发后端** [ai-tools.tsx:1195](core/frontend/src/routes/ai-tools.tsx#L1195)
|
||||
- 提交只发 `{prompt, mode, count}` [api.ts:342](core/frontend/src/api.ts#L342);后端 `mode` 只决定任务类型/分类/命名,不加工 prompt [services.py:1067](core/backend/apps/ai/services.py#L1067)
|
||||
|
||||
### 1.4 视频
|
||||
- 视频提示词织入风格锚点+旁白+画面 [services.py:621](core/backend/apps/ai/services.py#L621)
|
||||
- 已传参考图给 Seedance [services.py:870-897](core/backend/apps/ai/services.py#L870-L897)
|
||||
- 但 `generate_audio` 写死 False [volcano.py:129](core/backend/apps/ai/providers/volcano.py#L129)
|
||||
|
||||
---
|
||||
|
||||
## 2'. 开工就绪状态(2026-06-17 全部核实)
|
||||
|
||||
| 卡点 | 状态 |
|
||||
|---|---|
|
||||
| 电商 skill | ✅ 已写好 `skills/ecommerce-video-script/`(SKILL.md+5 references),**输出契约与 §3 完全对齐**(还多了 aspect_ratio + 四档时长) |
|
||||
| 参考图出图 | ✅ tokenssr gpt-image-2 `images/edits` 实测(含多图) |
|
||||
| 火山审核+绿/红标 | ✅ **AirDrama 有整套现成**:`video-shuoshan/backend/utils/assets_client.py`(建组/传素材/查审核)+ AssetGroup/Asset 模型(status: processing→**active=绿标 / failed=红标**)+ 轮询接口。照搬即可。策略:**一团队一素材组**(火山确认单组可放 500 万素材),后台 API 静默自动上传,前端不显示组、只显示绿/红标;红标提示"未通过审核,请改提示词重生" |
|
||||
| 模特库 | ✅ 由 agent 用 gpt-image-2 预生成 2-3 个(先 9:16 正面氛围图 → 以其为参考出 16:9 白底三视图)。提示词见 §阶段4 |
|
||||
| 前端结构 | ✅ routes 齐全,不用大动,主要接线+结构化渲染+聊天框+badge |
|
||||
| 多模型 | ✅ 豆包/GPT/Gemini 实测可用 |
|
||||
|
||||
### ★ 架构原则:Provider 可插拔(不写死中转站)
|
||||
**除火山(ARK/Seedance,官方直连)外,其余全走中转站且随时可换** → 设计成:
|
||||
- **一个通用 `OpenAICompatibleProvider(base_url, api_key)`** 覆盖所有中转站(tokenssr/yunqi/未来任意家),按 `ModelConfig.provider` 的 `base_url`+`api_key` 实例化。
|
||||
- **换中转站 = 改 DB 里 Provider 的 base_url+api_key,零改代码**;加新家 = 加一行(格式兼容则复用同一 provider 类)。
|
||||
- `VolcanoArkProvider` 专管火山官方(文本/图/Seedance)。
|
||||
- 新增 `ModelConfig.api_key`;key 全部走 .env / DB,**不写死代码**。
|
||||
|
||||
## 2. ⚠️ 前置硬伤 / 必须先决策
|
||||
|
||||
### GAP-1 参考图生成 —— ✅ 已解决(2026-06-17 实测,换中转站 tokenssr)
|
||||
|
||||
**结论:换到中转站 `tokenssr`(base `https://king.tokenssr.com/v1`)后,gpt-image-2 直接支持参考图(含多图)。** 之前 YunQi 是渠道残废,不是 gpt-image-2 本身不行。
|
||||
|
||||
- **一把 key 通吃 90 个模型**(无 YunQi 那种分组割裂):gpt-image-1 / gpt-image-1.5 / **gpt-image-2** / dall-e-3 + 全套 Gemini 文本&图像模型 + gpt-5-nano 等。
|
||||
- **gpt-image-2 传参考图,两条路实测 200:**
|
||||
- **A·`images/edits`(标准 OpenAI 格式,推荐)**:multipart,`image[]` 传 **1 张或多张**参考图 + `prompt` + `model=gpt-image-2` + `size`;返回 `data[0].b64_json`。**多图参考(2张)实测 200** → 故事板 `@图1@图2@图3` 成立。
|
||||
- **B·`images/generations` + `image`(base64)**:返回 `data[0].url`(`r2.sysrv.net/...png`,直接是托管 URL)。
|
||||
- 参考图来源:角色/场景/商品图在 TOS(URL)→ 取字节走 edits 的 `image[]`,或 base64 走 generations;出图落 TOS。
|
||||
- **需新增 `TokenssrProvider`**:`image_generate()`(文生图)/`image_edit(model,prompt,ref_bytes[],size)`(参考图,multipart image[])。比之前 Gemini-chat 解析 markdown 的方案**更干净**(标准 OpenAI 格式、data[] 直接拿图)。
|
||||
- 备选:同站的 Gemini 图像模型(`gemini-3-pro-image-preview`)走 chat 多模态也可用,留作 fallback/风格选择。
|
||||
- **这是图片/故事板/视频趴的前置依赖,现已具备。**
|
||||
|
||||
### GAP-2 Seedance 音频被关
|
||||
- 你的设计:Seedance 直接出画面+音效+人声,不走 TTS。
|
||||
- 现 `generate_audio=False` 写死 [volcano.py:129](core/backend/apps/ai/providers/volcano.py#L129) → 需打开;音色一致性(audioReference,Seedance-2.0 支持 [catalog.py:67](core/backend/apps/ai/catalog.py#L67))二期再接。
|
||||
|
||||
### 决策清单
|
||||
1. **图片参考图模型**(已定·实测解决):用 **tokenssr 的 gpt-image-2**(`images/edits` multipart `image[]`,多图已验证);YunQi Gemini 图像模型留可选 fallback。
|
||||
2. **计费粒度**(已定):**按实际 token 消耗,每次调用扣一次费**。含义:agent 自检循环每跑一轮都扣 → 循环要克制(≤2-3 轮),内部调用尽量精简,避免空转扣费。
|
||||
3. **平台套图(独立图片工具)**(已定):暂不管,本方案聚焦视频项目流水线。
|
||||
4. **多 key 管理**(基本解决):换 tokenssr 后,一把 key 通吃 90 模型,不再有 YunQi 分组割裂问题。后端仍加 `ModelConfig.api_key` 字段(留作多 provider 共存:tokenssr / 火山本地)。
|
||||
|
||||
---
|
||||
|
||||
## 3. 数据契约:结构化脚本 `ScriptDraft`(全链路地基)
|
||||
|
||||
把脚本从"散文+正则"改成**结构化输出**(JSON/tool-calling),模型无关,一次喂饱全下游。
|
||||
|
||||
```jsonc
|
||||
{
|
||||
"hook": "前3秒主打钩子(一句话)",
|
||||
"tone": "种草|测评|剧情|痛点",
|
||||
"aspect_ratio": "9:16", // 不写死,可 16:9/1:1/4:5,透传下游
|
||||
"total_duration": 60, "segment_count": 4, // 时长 15/30/60/90 四档,每15s一镜→1/2/4/6镜
|
||||
|
||||
"entities": [ // 角色/场景/商品,全脚本共享 → 保证4镜同一角色同一张脸/同一音色
|
||||
{ "id": "c1", "type": "character|scene|product", "name": "女主",
|
||||
"visual_prompt": "给图模型的生图提示词(AI自动生成,小白不打字)",
|
||||
"ref_index": 1,
|
||||
"voice_ref": "可选·角色音色参考(二期锁音色)" }
|
||||
],
|
||||
|
||||
"segments": [
|
||||
{ "index": 0, "duration": 15,
|
||||
"role": "钩子|痛点|卖点|CTA", // 电商脚本的灵魂
|
||||
"narration": "本镜要说出来的台词/旁白,≤55字(Seedance 15秒内直接发声,字数=可懂语速上限)",
|
||||
"speaker": "可选·指向角色entity的id;null=画外旁白",
|
||||
"visual": "画面描述",
|
||||
"product_exposure": "商品露出方式(手持/特写/使用中)",
|
||||
"entity_refs": ["c1"] }
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
**它如何驱动全下游:**
|
||||
- 图片趴:直接读 `entities[].visual_prompt` 预填出图卡(小白不打字)
|
||||
- 故事板趴:按 `entity_refs` + `ref_index` 拼 `@图N`
|
||||
- 视频趴:`narration`(发声)+ `visual` + `@图N` 参考 → Seedance
|
||||
- 一致性:同一 entity 全程一份图/一份音色
|
||||
|
||||
> 从 AirDrama **保留**:结构化思维、entity 层(一致性)、segment role。**砍掉**:三流、5 种 beat、est_sec、Section 嵌套、质量词三层 fork。
|
||||
|
||||
---
|
||||
|
||||
## 4. 分阶段落地
|
||||
|
||||
### 阶段 0 · 电商剧情 skill —— ✅ 已完成
|
||||
- 产物在 `skills/ecommerce-video-script/`(SKILL.md + 5 references:methodology/hook-library/category-playbook/platform-tone/checklist)。
|
||||
- 输出契约 = §3 ScriptDraft(并扩展了 aspect_ratio + 15/30/60/90 四档时长);3 模式路由、输出前自检、写作红线、渐进披露齐全。
|
||||
- agent(阶段2)运行时加载它作为领域知识。**无需再做。**
|
||||
|
||||
### 阶段 1 · 结构化契约 + 多模型管子(后端 ~1d)
|
||||
- 定 `ScriptDraft` 类型(§3);脚本输出从正则改 **tool-calling/JSON**
|
||||
- 文本 provider 分流(照搬生图)或全走 YunQi 网关;`/api/ai/models/` 已能列模型,生成时透传 `model_config_id`
|
||||
- DB:加 **GPT-5.5** + **Gemini-3.5-flash/3.1-pro**(均 YunQi 已实测可用)文本模型设 active;按模型配各自 key(决策4)
|
||||
- **验收**:同一商品,豆包 / GPT-5.5 / Gemini 三家都产出合规 `ScriptDraft`,字段不崩
|
||||
- 依赖:无 ‖ 可与阶段0并行
|
||||
|
||||
### 阶段 2 · 对话式脚本 agent(出稿+改稿一体)(后端 ~2-3d)
|
||||
- **同一个 agent 跑在带状态会话上**:第1轮=出稿(3模式 seed),第N轮=改稿(带上轮草稿)
|
||||
- Session 状态:当前 `ScriptDraft` + 消息历史 + 商品上下文 + 选定模型/调性
|
||||
- 工具:`get_product / load_skill / validate / emit` + `edit_segment(index,instruction)` / `patch_draft`(`build_segment_rerun_prompt` [services.py:353](core/backend/apps/ai/services.py#L353) 升级为工具)
|
||||
- 有界循环 ≤2-3 轮:读(草稿+用户话)→ 判(整篇重生/改某镜/答疑)→ 执行 → validate → emit
|
||||
- 一个 endpoint:`(session_id, 可选消息, 当前草稿) → (新草稿, agent回话)`
|
||||
- 计费:**按实际 token、每次调用扣**(决策2)→ 循环 ≤2-3 轮、内部调用精简,避免空转扣费
|
||||
- **验收**:点按钮 ~10-20s 出 4 镜结构化脚本;聊一句能精准改某镜;旁白≤55字、结构齐、entity 一致
|
||||
- 依赖:阶段0 + 阶段1
|
||||
|
||||
### 阶段 3 · 前端脚本趴(前端 ~1.5d)
|
||||
- 模型下拉(豆包/GPT/Gemini)
|
||||
- 3 种出稿输入(全自动/一句话/自带脚本)
|
||||
- 4 镜结构化渲染(钩子/痛点/卖点/CTA 标签 + 旁白/画面/商品露出)
|
||||
- 聊天框(改稿)+ 每镜快捷按钮(重跑/改活泼/加钩子=预设改稿指令)
|
||||
- **验收**:设计师视角点按钮出稿、看得到分镜结构、能对话改 / 点按钮改单镜
|
||||
- 依赖:阶段2
|
||||
|
||||
### 阶段 4 · 图片趴(后端+前端)
|
||||
- **前置·新增 `TokenssrProvider`**:`image_generate()`(文生图)/ `image_edit(model,prompt,ref_bytes[],size)`(参考图,`images/edits` multipart `image[]`);新增 `ModelConfig.api_key`
|
||||
- **三视图**(商品创建即可生,视频项目内可补):prompt + 上传商品图(正/侧/背多参考)→ **gpt-image-2 `images/edits`** 出三视图;无则用原图
|
||||
- **实体出图**:直接读脚本 `entities[].visual_prompt` 预填卡,小白只点生成(不打字);不满意可改 prompt
|
||||
- **模特库**(预生成 2-3 个电商模特,流程:① gpt-image-2 出 9:16 正面氛围图 → ② 以①为参考图出 16:9 白底三视图,提示词:`参考图1角色,生成角色三视图,从左往右依次为:胸像特写,全身正面,全身侧面,全身背面,白色背景`)+ 自定义模特/场景(gpt-image-2 生)
|
||||
- 出图落 TOS,大图预览
|
||||
- **火山审核**(照搬 AirDrama `utils/assets_client.py` + AssetGroup/Asset 模型 + 轮询接口):一团队一素材组,后台静默自动上传(前端不显示组),轮询 status → **active=绿盾标 / failed=红标**(红标提示"未通过审核,请改提示词重生");env 复用 TOS_ACCESS_KEY/TOS_SECRET_KEY + ASSETS_API_ENABLED + PROJECT_NAME
|
||||
- **验收**:小白零输入出齐角色/场景/商品图;三视图可参考原图;盾牌 icon 正确
|
||||
- 依赖:阶段2(脚本 entities)
|
||||
|
||||
### 阶段 5 · 故事板趴(依赖阶段4)
|
||||
- 拼装系统提示词 + 角色图/场景图/商品图(三视图优先)+ 本段 15s 脚本 → **gpt-image-2 `images/edits` 多图 `image[]`** 出导演故事板(多图实测 200)
|
||||
- 提示词骨架:`制作导演故事板指导seedance / @图1角色 @图2场景 / 分镜脚本(15s) / 规则:景别·运镜·画面·角色动作·情绪·对白旁白·灯光`
|
||||
- 每帧场景/角色必须回溯脚本文字(靠 `entity_refs`)
|
||||
- **验收**:每镜一张故事板,角色/场景跟回脚本,可指导视频
|
||||
- 依赖:阶段4
|
||||
|
||||
### 阶段 6 · 视频趴(依赖阶段5)
|
||||
- 分镜图+角色图+场景图+商品图+文字脚本 → Seedance(火山,参考图走 TOS URL,已在用)
|
||||
- v1 固定模板:`【设定】@图1是X角色,@图2是X场景,@图3是X商品 【分镜】根据@图4分镜图生成X商品视频 【脚本】…`(后续可做视频优化 skill)
|
||||
- 打开 `generate_audio`(GAP-2);二期接 audioReference 锁音色
|
||||
- 火山审核盾牌 icon 同图片趴
|
||||
- **⚠️ Seedance 出片慢:正常 5-10 分钟/段,凌晨可能 3-4 分钟**。轮询超时要放长(≥15 分钟),测试时别误判失败;若实测耗时太长,视频趴只到"提交成功+轮询机制验证",标注成熟度交接。
|
||||
- **验收**:四镜成片、画面跟故事板、音画自带、审核 icon 正确
|
||||
- 依赖:GAP-2 + 阶段5
|
||||
|
||||
---
|
||||
|
||||
## 5. 全链路数据流
|
||||
|
||||
```
|
||||
商品(信息+图) ──┐
|
||||
├─→ [脚本 agent] → ScriptDraft(hook/entities/segments)
|
||||
前置条件(调性/平台)┘ │
|
||||
├─ entities[].visual_prompt ─→ [图片趴] 角色/场景/商品图(三视图优先)
|
||||
│ │
|
||||
├─ entity_refs + 15s脚本 ──────────────→ [故事板趴] 导演分镜图
|
||||
│ │
|
||||
└─ narration + @图N + 分镜图 ──────────→ [视频趴] Seedance 成片(自带音)
|
||||
│
|
||||
全程:同一 entity = 同一张脸/同一音色 ;火山审核盾牌 icon
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 5'. 执行须知(compaction-safe resume 锚点 · 2026-06-17 夜)
|
||||
|
||||
> 压缩/换会话后,从这里 + 下面的 checklist 直接续干,不必重读全部代码。
|
||||
|
||||
**凭证(都在 `core/backend/.env`):**
|
||||
- `TOKENSSR_API_KEY` / `TOKENSSR_BASE_URL`(`https://king.tokenssr.com/v1`)= 主力中转站,一把 key 通吃 90 模型。
|
||||
- `FEISHU_APP_ID` / `FEISHU_APP_SECRET`(机器人「小毛球」,文件上传权限已开)。
|
||||
- 火山:`VOLCANO_ARK_API_KEY`、`TOS_*`、`VOLC_TTS_*` 已就绪。
|
||||
|
||||
**已验证可用模型(tokenssr,均 200):** 文本 `gpt-5.5`/`gemini-3.1-pro-preview`/`gemini-3.5-flash`;图编辑 `gpt-image-2`(`images/edits` multipart `image[]`,1~多张参考图,返回 b64)+ `images/generations`(文生图);Gemini 图像 `gemini-3-pro-image-preview`(chat 多模态,备选)。豆包/Seedance 走火山 ARK 本地。
|
||||
|
||||
**关键代码事实(已读):**
|
||||
- `ai/models.py`:`ModelProvider`(name/base_url/metadata,**需加 `api_key` 字段+迁移**)、`ModelConfig`(provider FK/name/capability/endpoint/status,默认选 `get_default_model`=该能力最早 active)、`AITask`(idempotency_key 必填唯一、status 机、request/response_payload、estimated/actual_cost)。
|
||||
- `ai/services.py`:`create_ai_task`→`reserve_credit`;`charge_reserved_credit`/`release_credit`(billing/services/ledger.py,幂等带行锁);`get_image_provider` 按 provider.name 分流;`build_*_prompt` 系列;`generate_project_script`(改结构化输出落 `ScriptVersion.metadata`)。
|
||||
- `providers/`:`base.py`(AIProvider Protocol)、`volcano.py`(VolcanoArkProvider:chat_completion/image_generation 带 `image` 参/create_video_task 有 `generate_audio=False` **需开**)、`yunqi.py`。**要新增 `OpenAICompatibleProvider(base_url,api_key)`** 覆盖所有中转站 + tokenssr 的 `image_edit`(images/edits multipart image[])。
|
||||
- `projects/models.py`:`ScriptVersion`(content+metadata)、`ScriptSegment`(narration/visual_prompt/duration,**需补 role/product_exposure/entity_refs/speaker 或塞 metadata**)、`BaseAssetGroup`(kind: product/person/scene,adopted/candidate assets)、`StoryboardVersion/Frame`、`VideoSegment/Version`。
|
||||
- `assets/models.py`:`Asset`(category: person/scene/product_image/…)+`AssetFile`(object_key/bucket/preview_url)。出图落 TOS 已有链路。
|
||||
- 前端 routes 齐全:`pipeline.tsx`(五阶段主)、`projects.tsx`、`ai-tools.tsx`、`api.ts`、`types.ts`。不大改,只接线+结构化渲染+按钮+绿红标。
|
||||
|
||||
**火山审核复用(照搬 AirDrama,有 4 个坑已处理):** 源 `video-shuoshan/backend/utils/assets_client.py`(create_asset_group/create_asset/get_asset)+ AssetGroup/Asset 模型(status active=绿/failed=红/processing)+ 轮询。策略:一团队一素材组,后台静默上传 TOS URL,前端只显示绿/红标。
|
||||
- **坑1·SDK**:AirShelf `requirements.txt` 没 `volcengine`(只有 boto3+requests)→ 加 `volcengine` 依赖装上(或用 requests 手写火山签名)。
|
||||
- **坑2·密钥**:AirDrama 用 `settings.TOS_ACCESS_KEY/TOS_SECRET_KEY`;AirShelf 改用**独立 env** `ASSETS_API_ACCESS_KEY/ASSETS_API_SECRET_KEY`(已写入 .env,**暂借 AirDrama 已邀测开通的 AK/SK** `AKLTNGJi…`)。搬代码时把读 key 处改成读这俩。
|
||||
- **坑3·配置**:`.env` 已加 `ASSETS_API_ENABLED=true` + `ASSETS_API_PROJECT_NAME=int_dev_Airlabs`(assets_client 里 PROJECT_NAME 常量改读这个 env)。
|
||||
- **坑4·权限(用户已拍板)**:AirShelf 自有火山账号没开通人像素材库邀测 → **先用 AirDrama 的 AK/SK 跑通**;**交接 MD 必须标红**:张业昌待办 = AirShelf 火山账号开通人像素材库后,把 .env 的 `ASSETS_API_ACCESS_KEY/SECRET` 换成自己的(独立两行,不动 TOS_*)。
|
||||
|
||||
**模特库提示词:** ① gpt-image-2 出 9:16 正面氛围电商模特 → ② 以①为参考:`参考图1角色,生成角色三视图,从左往右依次为:胸像特写,全身正面,全身侧面,全身背面,白色背景`(16:9)。
|
||||
|
||||
**收尾:** 推 dev 分支;飞书小毛球发交接 MD 给 **张业昌 13725102796** + **尹雨萱 15915987827**(测试号 13811803069 已验证可发文件)+ 附言(署名 Claude + 用户),让他们明早接手。飞书流程:token→batch_get_id(mobile→open_id)→ 上传文件(/im/v1/files,file_type=stream)→ 发文件消息(msg_type=file)。
|
||||
|
||||
**skill:** `skills/ecommerce-video-script/`(SKILL.md+5 references),输出契约 = §3 ScriptDraft(+aspect_ratio + 15/30/60/90 四档),agent 运行时加载它。
|
||||
|
||||
## 6. 关键路径
|
||||
|
||||
- **阶段 0(skill)+ 阶段 1(契约/多模型)并行先启** = 整条线地基
|
||||
- **GAP-1 已解决**(tokenssr gpt-image-2 参考图实测可用),图片/故事板/视频不再被阻塞
|
||||
- 阶段 2 是把 skill+契约粘起来的执行器;阶段 3 让设计师能用
|
||||
- 图片/故事板/视频(4/5/6)在脚本跑通后顺序推进
|
||||
- 唯一待你拍板:**决策4(多 key 管理方式)** —— 不阻塞阶段 0/1,可边做边定
|
||||
+16
-1
@@ -10,7 +10,7 @@ DB_USER=airshelf_app
|
||||
DB_PASSWORD=d5020f4d41e0e4c52a371ecb913be3d1f1ab2b85
|
||||
DB_HOST=14.103.27.192
|
||||
DB_PORT=3306
|
||||
DB_BIND_ADDRESS=192.168.124.137
|
||||
# DB_BIND_ADDRESS=192.168.124.137 # local-dev blanked: upstream dev-host LAN addr, not present on this machine -> WinError 10049
|
||||
REDIS_CACHE_URL=redis://zyc:Zyc188208@redis-shzlsczo52dft8mia.redis.volces.com:6379/0
|
||||
CELERY_BROKER_URL=redis://zyc:Zyc188208@redis-shzlsczo52dft8mia.redis.volces.com:6379/1
|
||||
CELERY_RESULT_BACKEND=redis://zyc:Zyc188208@redis-shzlsczo52dft8mia.redis.volces.com:6379/2
|
||||
@@ -25,6 +25,21 @@ DEFAULT_TRIAL_CREDITS=1000.0000
|
||||
YUNQI_API_KEY=sk-xdP2iy5kzmehinLkI1lxV2BmpGSXma2wvKbSVP3tZBPHH6zf
|
||||
YUNQI_BASE_URL=https://www.yunqiai.chat/v1
|
||||
|
||||
# tokenssr 中转站(主力:gpt-image-2 参考图 + gpt-5.5/gemini 文本 + gemini 图像)· 一把 key 通吃 90 模型
|
||||
TOKENSSR_API_KEY=sk-vDg00IAX6EW4ABo4ePwAFPRaIjScFLUs1ZM1lInZJvs7z6M0
|
||||
TOKENSSR_BASE_URL=https://king.tokenssr.com/v1
|
||||
|
||||
# 飞书机器人「小毛球」(交接文档推送)· 凭证也在 AirDrama utils/alert_service.py
|
||||
FEISHU_APP_ID=cli_a90478156bf85bd7
|
||||
FEISHU_APP_SECRET=87N2nnx6Yv56TPjl2GraLdKOjFiGOSGp
|
||||
|
||||
# 火山人像素材库(审核绿/红标)· ⚠️ 暂借 AirDrama 已邀测开通的 AK/SK,AirShelf 自有账号开通后换这两把
|
||||
# 张业昌待办:换成 AirShelf 自己火山账号的 AK/SK(独立于上面 TOS_*,只改这两行)
|
||||
ASSETS_API_ACCESS_KEY=AKLTNGJiNzg2Y2I0NzlhNGRkM2FmYzAwYTliYmZkNzUxYzU
|
||||
ASSETS_API_SECRET_KEY=WXpZeE5UQTRPV0prTXpJeU5HVTNORGxpTURjeE9ETXlOakl6TldKbU0yVQ==
|
||||
ASSETS_API_ENABLED=true
|
||||
ASSETS_API_PROJECT_NAME=int_dev_Airlabs
|
||||
|
||||
# 豆包语音合成(旁白配音 TTS)· 火山控制台-语音技术-语音合成
|
||||
VOLC_TTS_APPID=8945759494
|
||||
VOLC_TTS_ACCESS_TOKEN=w7Ye8FdTHADU05PV5cVNjud8FseOnYzR
|
||||
|
||||
@@ -186,4 +186,23 @@ YUNQI = {
|
||||
"base_url": env("YUNQI_BASE_URL", "https://www.yunqiai.chat/v1"),
|
||||
}
|
||||
|
||||
# tokenssr 中转站:一把 key 通吃 ~90 模型(gpt-image-2 参考图 / gpt-5.x / 全套 Gemini 文本&图像)。
|
||||
TOKENSSR = {
|
||||
"api_key": env("TOKENSSR_API_KEY", ""),
|
||||
"base_url": env("TOKENSSR_BASE_URL", "https://king.tokenssr.com/v1"),
|
||||
}
|
||||
|
||||
# 中转站凭证回退表(provider.name → .env)。可插拔解析顺序见 services.resolve_provider_credentials:
|
||||
# DB 的 ModelProvider.base_url/api_key 优先,留空才回退到这里。密钥只在 .env,不写死、不强制进库。
|
||||
PROVIDER_BASE_URLS = {
|
||||
"volcengine": env("VOLCANO_ARK_BASE_URL", "https://ark.cn-beijing.volces.com/api/v3"),
|
||||
"yunqi": env("YUNQI_BASE_URL", "https://www.yunqiai.chat/v1"),
|
||||
"tokenssr": env("TOKENSSR_BASE_URL", "https://king.tokenssr.com/v1"),
|
||||
}
|
||||
PROVIDER_KEYS = {
|
||||
"volcengine": env("VOLCANO_ARK_API_KEY", ""),
|
||||
"yunqi": env("YUNQI_API_KEY", ""),
|
||||
"tokenssr": env("TOKENSSR_API_KEY", ""),
|
||||
}
|
||||
|
||||
DEFAULT_TRIAL_CREDITS = env("DEFAULT_TRIAL_CREDITS", "100.0000")
|
||||
|
||||
@@ -0,0 +1,43 @@
|
||||
"""预生成电商模特库(gpt-image-2:9:16 氛围正面图 → 16:9 白底三视图)。
|
||||
|
||||
用法:
|
||||
python manage.py seed_demo_models --count 2 [--team <team_id>] [--brief "自定义画像"]
|
||||
默认给「第一个有成员的团队」生成 N 个默认人设。慢(每个模特 2 次 gpt-image-2,约 1-2 分钟)。
|
||||
"""
|
||||
from django.core.management.base import BaseCommand, CommandError
|
||||
|
||||
from apps.ai.model_library import DEFAULT_MODEL_BRIEFS, generate_model
|
||||
|
||||
|
||||
class Command(BaseCommand):
|
||||
help = "用 gpt-image-2 预生成电商模特(正面氛围图 + 白底三视图),存为 person 资产"
|
||||
|
||||
def add_arguments(self, parser):
|
||||
parser.add_argument("--count", type=int, default=2, help="生成几个模特(默认 2)")
|
||||
parser.add_argument("--team", type=str, default="", help="指定团队 id(默认第一个团队)")
|
||||
parser.add_argument("--brief", type=str, default="", help="自定义单个模特画像(给了就只生成这一个)")
|
||||
|
||||
def handle(self, *args, **opts):
|
||||
from apps.accounts.models import Team, User
|
||||
|
||||
team = Team.objects.filter(id=opts["team"]).first() if opts["team"] else Team.objects.order_by("created_at").first()
|
||||
if team is None:
|
||||
raise CommandError("找不到团队,请先建团队或用 --team 指定")
|
||||
user = (
|
||||
User.objects.filter(team_memberships__team=team).order_by("date_joined").first()
|
||||
or getattr(team, "owner", None)
|
||||
or User.objects.order_by("date_joined").first()
|
||||
)
|
||||
|
||||
briefs = [opts["brief"]] if opts["brief"] else DEFAULT_MODEL_BRIEFS[: max(1, opts["count"])]
|
||||
self.stdout.write(f"团队={team.id} 用户={getattr(user, 'username', None)} · 生成 {len(briefs)} 个模特…")
|
||||
for i, brief in enumerate(briefs, 1):
|
||||
self.stdout.write(f" [{i}/{len(briefs)}] {brief} … 生成中(gpt-image-2,稍候)")
|
||||
try:
|
||||
res = generate_model(team=team, user=user, brief=brief)
|
||||
self.stdout.write(self.style.SUCCESS(
|
||||
f" ✓ 正面={res['frontal'].id} 三视图={res['three_view'].id}"
|
||||
))
|
||||
except Exception as exc: # noqa: BLE001
|
||||
self.stderr.write(self.style.ERROR(f" ✗ 失败:{exc}"))
|
||||
self.stdout.write(self.style.SUCCESS("done"))
|
||||
@@ -0,0 +1,18 @@
|
||||
# Generated by Django 5.1.15 on 2026-06-16 17:50
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('ai', '0004_seed_tts_model'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name='modelprovider',
|
||||
name='api_key',
|
||||
field=models.CharField(blank=True, max_length=255),
|
||||
),
|
||||
]
|
||||
@@ -0,0 +1,82 @@
|
||||
"""Seed tokenssr 中转站 provider + 多模型(文本 GPT-5.5 / Gemini-3-Pro,图像 gpt-image-2 支持参考图)。
|
||||
|
||||
可插拔:tokenssr provider 的 base_url 存 DB(换站改这里),api_key 留空 → 运行时回退 settings.PROVIDER_KEYS(.env)。
|
||||
图像主力切到 tokenssr:gpt-image-2(images/edits 多图参考),停用只能纯文生图的 yunqi:gpt-image-2。
|
||||
幂等:全部 get_or_create / update_or_create,可重复 apply。
|
||||
"""
|
||||
from django.db import migrations
|
||||
|
||||
TOKENSSR_BASE_URL = "https://king.tokenssr.com/v1"
|
||||
|
||||
TEXT_MODELS = [
|
||||
# name, display_name, metadata
|
||||
("gpt-5.5", "GPT-5.5", {"family": "gpt", "recommended": True}),
|
||||
("gemini-3-pro-preview", "Gemini 3 Pro", {"family": "gemini"}),
|
||||
]
|
||||
IMAGE_MODELS = [
|
||||
("gpt-image-2", "GPT-Image-2(参考图)", {"family": "gpt", "supports_reference": True, "recommended": True}),
|
||||
("gemini-2.5-flash-image", "Gemini 2.5 Flash Image", {"family": "gemini", "supports_reference": True}),
|
||||
]
|
||||
|
||||
|
||||
def seed(apps, schema_editor):
|
||||
ModelProvider = apps.get_model("ai", "ModelProvider")
|
||||
ModelConfig = apps.get_model("ai", "ModelConfig")
|
||||
|
||||
tokenssr, _ = ModelProvider.objects.get_or_create(
|
||||
name="tokenssr",
|
||||
defaults={
|
||||
"display_name": "tokenssr 中转站",
|
||||
"status": "active",
|
||||
"base_url": TOKENSSR_BASE_URL,
|
||||
"api_key": "", # 留空:运行时从 .env(PROVIDER_KEYS)解析,密钥不进库
|
||||
"metadata": {"kind": "relay", "note": "一把 key 通吃 ~90 模型"},
|
||||
},
|
||||
)
|
||||
if not tokenssr.base_url:
|
||||
tokenssr.base_url = TOKENSSR_BASE_URL
|
||||
tokenssr.save(update_fields=["base_url"])
|
||||
|
||||
for name, display, meta in TEXT_MODELS:
|
||||
ModelConfig.objects.update_or_create(
|
||||
provider=tokenssr,
|
||||
name=name,
|
||||
capability="text",
|
||||
defaults={
|
||||
"display_name": display,
|
||||
"endpoint": "chat/completions",
|
||||
"unit_price": "1.0000",
|
||||
"status": "active",
|
||||
"metadata": meta,
|
||||
},
|
||||
)
|
||||
for name, display, meta in IMAGE_MODELS:
|
||||
ModelConfig.objects.update_or_create(
|
||||
provider=tokenssr,
|
||||
name=name,
|
||||
capability="image",
|
||||
defaults={
|
||||
"display_name": display,
|
||||
"endpoint": "images/generations",
|
||||
"unit_price": "2.0000",
|
||||
"status": "active" if name == "gpt-image-2" else "disabled",
|
||||
"metadata": meta,
|
||||
},
|
||||
)
|
||||
|
||||
# 图像主力切到 tokenssr:gpt-image-2;停用只能纯文生图的 yunqi:gpt-image-2(参考图分镜要靠 tokenssr)
|
||||
ModelConfig.objects.filter(provider__name="yunqi", capability="image").update(status="disabled")
|
||||
|
||||
|
||||
def unseed(apps, schema_editor):
|
||||
# 反向:停用 tokenssr 模型并复活 yunqi 图像(不删数据,保守)
|
||||
ModelProvider = apps.get_model("ai", "ModelProvider")
|
||||
ModelConfig = apps.get_model("ai", "ModelConfig")
|
||||
ModelConfig.objects.filter(provider__name="tokenssr").update(status="disabled")
|
||||
ModelConfig.objects.filter(provider__name="yunqi", capability="image").update(status="active")
|
||||
ModelProvider.objects.filter(name="tokenssr").update(status="disabled")
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
dependencies = [("ai", "0005_modelprovider_api_key")]
|
||||
operations = [migrations.RunPython(seed, unseed)]
|
||||
@@ -0,0 +1,92 @@
|
||||
"""模特库生成:用 gpt-image-2 预生成电商真人模特。
|
||||
|
||||
流程(与用户定的 SOP 一致):
|
||||
1) 先出 9:16 竖屏氛围正面图(image_generation);
|
||||
2) 以正面图为参考,出 16:9 白底三视图(image_edit,提示词锁角色一致性)。
|
||||
两张都落 TOS,存为 person 类 Asset(metadata.kind="model"),进模特库/资产库。
|
||||
|
||||
可被管理命令(seed_demo_models)或后端业务复用。模型走默认 image 模型(当前 = tokenssr:gpt-image-2)。
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import uuid
|
||||
from io import BytesIO
|
||||
|
||||
from apps.ai.models import ModelConfig
|
||||
from apps.ai.services import _asset_preview_url, build_provider, get_default_model
|
||||
from apps.assets.models import Asset, AssetFile
|
||||
from apps.assets.storage import TosStorage
|
||||
|
||||
THREE_VIEW_PROMPT = (
|
||||
"参考图1角色,生成角色三视图,从左往右依次为:胸像特写,全身正面,全身侧面,全身背面,白色背景"
|
||||
)
|
||||
|
||||
|
||||
def _store_model_asset(*, team, user, media: str, name: str, brief: str, view: str) -> Asset:
|
||||
"""把生成的图片(url/base64)落 TOS,存为 person 类模特资产(非项目维度)。"""
|
||||
from apps.ai.providers import VolcanoArkProvider # media_to_bytes 复用
|
||||
|
||||
fileobj, content_type = VolcanoArkProvider.media_to_bytes(media)
|
||||
suffix = ".png"
|
||||
if "jpeg" in (content_type or ""):
|
||||
suffix = ".jpg"
|
||||
elif "webp" in (content_type or ""):
|
||||
suffix = ".webp"
|
||||
asset_id = uuid.uuid4()
|
||||
object_key = f"teams/{team.id}/models/{asset_id}{suffix}"
|
||||
raw = fileobj.getvalue()
|
||||
stored = TosStorage().upload_fileobj(fileobj=BytesIO(raw), object_key=object_key, content_type=content_type or "image/png")
|
||||
asset = Asset.objects.create(
|
||||
id=asset_id,
|
||||
team=team,
|
||||
created_by=user,
|
||||
name=name,
|
||||
asset_type=Asset.Type.IMAGE,
|
||||
source=Asset.Source.AI_GENERATED,
|
||||
category=Asset.Category.PERSON,
|
||||
metadata={"kind": "model", "brief": brief, "view": view},
|
||||
)
|
||||
AssetFile.objects.create(
|
||||
asset=asset,
|
||||
object_key=stored.object_key,
|
||||
bucket=stored.bucket,
|
||||
content_type=stored.content_type,
|
||||
size_bytes=stored.size_bytes,
|
||||
is_primary=True,
|
||||
)
|
||||
return asset
|
||||
|
||||
|
||||
def generate_model(*, team, user, brief: str, name: str | None = None) -> dict:
|
||||
"""生成一个电商模特(9:16 氛围正面图 + 16:9 白底三视图)。返回 {"frontal":Asset,"three_view":Asset}。"""
|
||||
model_config = get_default_model(ModelConfig.Capability.IMAGE)
|
||||
if model_config is None:
|
||||
raise ValueError("没有可用的图像模型(image capability)")
|
||||
provider = build_provider(model_config)
|
||||
if not hasattr(provider, "image_edit"):
|
||||
raise ValueError(f"当前图像模型 {model_config.provider.name}:{model_config.name} 不支持参考图三视图(image_edit)")
|
||||
|
||||
label = name or brief[:16]
|
||||
# 1) 9:16 氛围正面图
|
||||
frontal_prompt = f"{brief},电商真人模特,9:16竖屏氛围正面半身,自然妆容,柔和影棚光,真实质感,单人,简洁背景"
|
||||
resp = provider.image_generation(model=model_config.name, prompt=frontal_prompt, size="1024x1536")
|
||||
frontal = _store_model_asset(
|
||||
team=team, user=user, media=provider.extract_first_media_url(resp),
|
||||
name=f"{label}·正面氛围", brief=brief, view="frontal",
|
||||
)
|
||||
# 2) 16:9 白底三视图(以正面图为参考,锁角色一致性)
|
||||
frontal_url = _asset_preview_url(frontal)
|
||||
resp2 = provider.image_edit(model=model_config.name, prompt=THREE_VIEW_PROMPT, images=[frontal_url], size="1536x1024")
|
||||
three_view = _store_model_asset(
|
||||
team=team, user=user, media=provider.extract_first_media_url(resp2),
|
||||
name=f"{label}·三视图", brief=brief, view="three_view",
|
||||
)
|
||||
return {"frontal": frontal, "three_view": three_view}
|
||||
|
||||
|
||||
# 演示用默认模特画像(电商常用人设)
|
||||
DEFAULT_MODEL_BRIEFS = [
|
||||
"26岁都市白领女性,知性温柔,黑色及肩直发,米色针织衫",
|
||||
"30岁阳光运动男性,短发,健康肤色,浅灰色休闲卫衣",
|
||||
"22岁元气学生女生,马尾,清透妆,浅蓝色衬衫",
|
||||
]
|
||||
@@ -12,6 +12,9 @@ class ModelProvider(TimeStampedModel):
|
||||
display_name = models.CharField(max_length=128)
|
||||
status = models.CharField(max_length=24, choices=Status.choices, default=Status.ACTIVE)
|
||||
base_url = models.URLField(blank=True)
|
||||
# 站级 API Key(中转站)。可插拔:换站 = 改这一行的 base_url + api_key,零改代码。
|
||||
# 留空则 services 层按 provider.name 回退 settings.PROVIDER_KEYS(.env),避免密钥写死/进库。
|
||||
api_key = models.CharField(max_length=255, blank=True)
|
||||
metadata = models.JSONField(default=dict, blank=True)
|
||||
|
||||
def __str__(self) -> str:
|
||||
|
||||
@@ -1,6 +1,15 @@
|
||||
from .base import AIProvider, AIProviderResult
|
||||
from .openai_compatible import OpenAICompatibleProvider
|
||||
from .volcano import TtsNotConfigured, VolcanoArkProvider, VolcanoTtsProvider
|
||||
from .yunqi import YunqiProvider
|
||||
|
||||
|
||||
__all__ = ["AIProvider", "AIProviderResult", "TtsNotConfigured", "VolcanoArkProvider", "VolcanoTtsProvider", "YunqiProvider"]
|
||||
__all__ = [
|
||||
"AIProvider",
|
||||
"AIProviderResult",
|
||||
"OpenAICompatibleProvider",
|
||||
"TtsNotConfigured",
|
||||
"VolcanoArkProvider",
|
||||
"VolcanoTtsProvider",
|
||||
"YunqiProvider",
|
||||
]
|
||||
|
||||
@@ -0,0 +1,89 @@
|
||||
from typing import Any
|
||||
|
||||
import requests
|
||||
|
||||
from .volcano import VolcanoArkProvider
|
||||
|
||||
|
||||
class OpenAICompatibleProvider(VolcanoArkProvider):
|
||||
"""通用 OpenAI 兼容中转站适配器(tokenssr / yunqi / 任意 New-API 网关)。
|
||||
|
||||
设计目标:**可插拔**。凭证(base_url + api_key)由调用方显式注入——来自 DB
|
||||
ModelProvider 或 .env,**不绑定任何具体站点**。换中转站 = 改 base_url + api_key,
|
||||
零改代码。复用父类 VolcanoArkProvider 的 chat_completion / chat_completion_stream /
|
||||
extract_text / extract_first_media_url / media_to_bytes。
|
||||
|
||||
与火山 ARK 的差异:生图走标准 OpenAI 形态(images/generations / images/edits),
|
||||
不发 watermark / sequential_image_generation / response_format 等火山私有参数。
|
||||
"""
|
||||
|
||||
def __post_init__(self) -> None:
|
||||
# 关键:不回退到 settings.VOLCANO。凭证必须由 services 层显式注入,
|
||||
# 否则就退化成「写死火山」破坏可插拔性。base_url 必填;api_key 允许构造期为空,
|
||||
# 到真正调用时再报错(便于 seed / 探活阶段构造对象)。
|
||||
if not self.base_url:
|
||||
raise ValueError("OpenAICompatibleProvider requires base_url (中转站地址)")
|
||||
|
||||
def image_generation(
|
||||
self,
|
||||
*,
|
||||
model: str,
|
||||
prompt: str,
|
||||
endpoint: str = "images/generations",
|
||||
image: str | list[str] | None = None,
|
||||
size: str = "1024x1536",
|
||||
) -> dict[str, Any]:
|
||||
"""文生图(可选单图参考 base64)。多图参考请用 image_edit。返回体含 url 或 b64_json。"""
|
||||
if not self.api_key:
|
||||
raise ValueError("中转站 api_key 未配置")
|
||||
body: dict[str, Any] = {"model": model, "prompt": prompt, "size": size, "n": 1}
|
||||
if image:
|
||||
body["image"] = image
|
||||
# 实测中转站生图延迟可达 75s+,超时给到 300s
|
||||
response = requests.post(
|
||||
f"{self.base_url.rstrip('/')}/{endpoint.lstrip('/')}",
|
||||
headers={"Authorization": f"Bearer {self.api_key}", "Content-Type": "application/json"},
|
||||
json=body,
|
||||
timeout=300,
|
||||
)
|
||||
response.raise_for_status()
|
||||
return response.json()
|
||||
|
||||
def image_edit(
|
||||
self,
|
||||
*,
|
||||
model: str,
|
||||
prompt: str,
|
||||
images: list[str],
|
||||
endpoint: str = "images/edits",
|
||||
size: str = "1024x1536",
|
||||
) -> dict[str, Any]:
|
||||
"""参考图编辑/合成(gpt-image-2 核心能力):multipart `image[]` 上传一张或多张参考图。
|
||||
|
||||
images 元素可为 http(s) URL / data:base64 / 裸 base64(用父类 media_to_bytes 归一化为字节)。
|
||||
多图参考即「@图1 @图2 @图3」——故事板按角色/场景/商品多锚点合成。返回体含 b64_json。
|
||||
"""
|
||||
if not self.api_key:
|
||||
raise ValueError("中转站 api_key 未配置")
|
||||
files: list[tuple[str, tuple[str, bytes, str]]] = []
|
||||
for idx, ref in enumerate(images or []):
|
||||
fileobj, content_type = self.media_to_bytes(ref)
|
||||
content_type = content_type or "image/png"
|
||||
ext = "png"
|
||||
if "jpeg" in content_type or "jpg" in content_type:
|
||||
ext = "jpg"
|
||||
elif "webp" in content_type:
|
||||
ext = "webp"
|
||||
files.append(("image[]", (f"ref{idx + 1}.{ext}", fileobj.getvalue(), content_type)))
|
||||
if not files:
|
||||
raise ValueError("image_edit 至少需要一张参考图")
|
||||
data = {"model": model, "prompt": prompt, "size": size, "n": "1"}
|
||||
response = requests.post(
|
||||
f"{self.base_url.rstrip('/')}/{endpoint.lstrip('/')}",
|
||||
headers={"Authorization": f"Bearer {self.api_key}"}, # multipart 不要手设 Content-Type
|
||||
files=files,
|
||||
data=data,
|
||||
timeout=300,
|
||||
)
|
||||
response.raise_for_status()
|
||||
return response.json()
|
||||
@@ -1,8 +1,9 @@
|
||||
from dataclasses import dataclass
|
||||
import base64
|
||||
import json
|
||||
import uuid
|
||||
from io import BytesIO
|
||||
from typing import Any
|
||||
from typing import Any, Iterator
|
||||
|
||||
import requests
|
||||
from django.conf import settings
|
||||
@@ -52,6 +53,57 @@ class VolcanoArkProvider:
|
||||
response.raise_for_status()
|
||||
return response.json()
|
||||
|
||||
def chat_completion_stream(
|
||||
self,
|
||||
*,
|
||||
model: str,
|
||||
messages: list[dict[str, Any]],
|
||||
endpoint: str = "chat/completions",
|
||||
temperature: float = 0.8,
|
||||
extra_body: dict[str, Any] | None = None,
|
||||
) -> Iterator[dict[str, Any]]:
|
||||
"""流式对话:逐块 yield {type:'delta'|'tool_call'|'done', ...}。
|
||||
OpenAI 兼容 SSE(火山 ARK / 各中转站同构),供脚本 agent 的 SSE 端实时转发。"""
|
||||
if not self.api_key:
|
||||
raise ValueError("api_key is not configured")
|
||||
body: dict[str, Any] = {"model": model, "messages": messages, "stream": True, "temperature": temperature}
|
||||
if extra_body:
|
||||
body.update(extra_body)
|
||||
with requests.post(
|
||||
f"{self.base_url.rstrip('/')}/{endpoint.lstrip('/')}",
|
||||
headers={
|
||||
"Authorization": f"Bearer {self.api_key}",
|
||||
"Content-Type": "application/json",
|
||||
"Accept": "text/event-stream",
|
||||
},
|
||||
json=body,
|
||||
stream=True,
|
||||
timeout=300,
|
||||
) as response:
|
||||
response.raise_for_status()
|
||||
# SSE 响应常不带 charset,requests 会按 latin-1 解码 → 中文乱码。强制 UTF-8。
|
||||
response.encoding = "utf-8"
|
||||
for raw in response.iter_lines(decode_unicode=True):
|
||||
if not raw or not raw.startswith("data:"):
|
||||
continue
|
||||
data = raw[5:].strip()
|
||||
if data == "[DONE]":
|
||||
break
|
||||
try:
|
||||
chunk = json.loads(data)
|
||||
except ValueError:
|
||||
continue
|
||||
choices = chunk.get("choices") or []
|
||||
if not choices:
|
||||
continue
|
||||
delta = choices[0].get("delta") or {}
|
||||
piece = delta.get("content")
|
||||
if piece:
|
||||
yield {"type": "delta", "text": piece}
|
||||
if delta.get("tool_calls"):
|
||||
yield {"type": "tool_call", "tool_calls": delta["tool_calls"]}
|
||||
yield {"type": "done"}
|
||||
|
||||
@staticmethod
|
||||
def extract_text(data: dict[str, Any]) -> str:
|
||||
choices = data.get("choices") or []
|
||||
@@ -113,6 +165,7 @@ class VolcanoArkProvider:
|
||||
duration: int = 15,
|
||||
resolution: str = "720p",
|
||||
reference_images: list[str] | None = None,
|
||||
generate_audio: bool = True,
|
||||
) -> dict[str, Any]:
|
||||
if not self.api_key:
|
||||
raise ValueError("VOLCANO_ARK_API_KEY is not configured")
|
||||
@@ -126,7 +179,8 @@ class VolcanoArkProvider:
|
||||
"duration": duration,
|
||||
"resolution": resolution,
|
||||
"watermark": False,
|
||||
"generate_audio": False,
|
||||
# Seedance 直接出音效 + 人物声音(参考生视频);关掉则是哑片。默认开。
|
||||
"generate_audio": generate_audio,
|
||||
}
|
||||
response = requests.post(
|
||||
f"{self.base_url.rstrip('/')}/{endpoint.lstrip('/')}",
|
||||
|
||||
@@ -0,0 +1,494 @@
|
||||
"""对话式脚本生成 agent(出稿 + 改稿一体,多模型可选,流式 SSE)。
|
||||
|
||||
设计:
|
||||
- 加载电商脚本 skill(SKILL.md + references)作为领域知识系统提示词;模型无关。
|
||||
- 3 种输入模式(全自动 / 一句话 / 改稿)收敛到同一份结构化 ScriptDraft(铁律1契约)。
|
||||
- 流式:边生成边吐「工具卡 + 思考」事件,给前端真 agent 体感;JSON 由后端可靠抽取,不靠模型排版。
|
||||
- 计费走现有 AITask + 额度预扣(reserve→charge/release),与 generate_project_script 一致。
|
||||
|
||||
SSE 事件(每帧 `data: {json}\n\n`,json 带 type):
|
||||
tool {id,label?,status:running|done|error} —— 工具卡(加载skill/分析商品/生成分镜/提取实体/自检)
|
||||
delta {text} —— 模型自然语言前言(JSON 部分不外露)
|
||||
draft {draft} —— 规范化后的 ScriptDraft(前端结构化渲染)
|
||||
saved {script_version_id, version} —— 已落库的 ScriptVersion(含 segments/metadata)
|
||||
done {} —— 结束
|
||||
error {detail} —— 失败(已回滚额度)
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import re
|
||||
from functools import lru_cache
|
||||
from pathlib import Path
|
||||
|
||||
from django.conf import settings
|
||||
from django.utils import timezone
|
||||
|
||||
from apps.ai.models import AITask, ModelConfig
|
||||
from apps.billing.services.ledger import charge_reserved_credit, release_credit
|
||||
|
||||
VALID_TONES = ["种草", "测评", "剧情", "痛点"]
|
||||
VALID_ROLES = ["钩子", "痛点", "卖点", "CTA"]
|
||||
VALID_ENTITY_TYPES = ["character", "scene", "product"]
|
||||
DURATION_TIERS = [15, 30, 60, 90]
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# skill 加载(缓存)
|
||||
# --------------------------------------------------------------------------- #
|
||||
def _skill_dir() -> Path:
|
||||
override = getattr(settings, "ECOMMERCE_SKILL_DIR", None)
|
||||
if override:
|
||||
return Path(override)
|
||||
return Path(settings.BASE_DIR).parent.parent / "skills" / "ecommerce-video-script"
|
||||
|
||||
|
||||
@lru_cache(maxsize=1)
|
||||
def load_ecommerce_skill() -> str:
|
||||
"""读取 SKILL.md + 全部 references 拼成系统提示词(领域知识)。缺文件不致命,尽量给。"""
|
||||
skill_dir = _skill_dir()
|
||||
parts: list[str] = []
|
||||
main = skill_dir / "SKILL.md"
|
||||
if main.exists():
|
||||
parts.append(main.read_text(encoding="utf-8"))
|
||||
ref_dir = skill_dir / "references"
|
||||
if ref_dir.exists():
|
||||
for ref in sorted(ref_dir.glob("*.md")):
|
||||
parts.append(f"\n\n===== references/{ref.name} =====\n\n{ref.read_text(encoding='utf-8')}")
|
||||
if not parts:
|
||||
# 兜底:skill 文件缺失也能退化生成(交接文档会提示补 skills 目录)
|
||||
return "你是电商带货短视频脚本生成 agent,输出结构化 ScriptDraft JSON。"
|
||||
return "".join(parts)
|
||||
|
||||
|
||||
# 运行时输出协议:优先级高于 skill 里的「只输出 JSON / 不展示思考」,只为流式体感放开一句前言。
|
||||
_OUTPUT_PROTOCOL = """
|
||||
|
||||
---
|
||||
|
||||
## 运行时输出协议(AirShelf 流式展示专用,优先级高于技能正文的「只输出 JSON」)
|
||||
|
||||
严格按以下顺序输出,不要有别的内容:
|
||||
1. 先用 **1 句中文口语**告诉用户你正在做什么(≤40 字,例:「在为这款保温杯生成 4 镜痛点脚本…」),让用户看到进展;
|
||||
2. 紧接着输出**且仅输出一个** ```json 代码块,内容为符合技能契约(铁律1)的 ScriptDraft 对象;
|
||||
3. json 代码块之后**不要再写任何文字**。
|
||||
"""
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# 提示词构建(3 模式)
|
||||
# --------------------------------------------------------------------------- #
|
||||
def _product_context(project, selling_point_ids: list[str] | None) -> str:
|
||||
product = project.product
|
||||
selling_points = product.selling_points.all()
|
||||
if selling_point_ids:
|
||||
selling_points = selling_points.filter(id__in=selling_point_ids)
|
||||
selling_text = "\n".join(f"- {sp.title}:{sp.detail}" for sp in selling_points)
|
||||
return (
|
||||
f"商品标题:{product.title}\n"
|
||||
f"品牌:{product.brand or '未填写'}\n"
|
||||
f"类目:{product.category or '未填写'}\n"
|
||||
f"目标人群:{product.target_audience or '未填写'}\n"
|
||||
f"商品描述:{product.description or '未填写'}\n"
|
||||
f"卖点:\n{selling_text or '未勾选卖点,请根据商品信息自行提炼。'}"
|
||||
)
|
||||
|
||||
|
||||
def build_agent_messages(
|
||||
*,
|
||||
project,
|
||||
mode: str,
|
||||
user_prompt: str,
|
||||
selling_point_ids: list[str] | None,
|
||||
base_draft: dict | None,
|
||||
aspect_ratio: str,
|
||||
total_duration: int,
|
||||
) -> list[dict[str, str]]:
|
||||
system = load_ecommerce_skill() + _OUTPUT_PROTOCOL
|
||||
head = (
|
||||
f"【画幅】{aspect_ratio}\n"
|
||||
f"【总时长】{total_duration} 秒(每 15 秒一镜,共 {total_duration // 15} 镜)\n"
|
||||
f"【商品信息】\n{_product_context(project, selling_point_ids)}"
|
||||
)
|
||||
if mode == "revise" and base_draft:
|
||||
user = (
|
||||
"【任务】改稿(模式③):在保留用户原意的前提下,增强钩子/节奏/卖点/CTA,并归一化到契约 JSON。\n"
|
||||
f"{head}\n\n"
|
||||
f"【现有脚本 JSON】\n{json.dumps(base_draft, ensure_ascii=False)}\n\n"
|
||||
f"【用户修改意见】{user_prompt.strip() or '让整体更有吸引力、转化感更强,并保持各镜衔接连贯。'}\n\n"
|
||||
"请输出修订后的**完整** ScriptDraft。"
|
||||
)
|
||||
elif mode == "theme" or (user_prompt and user_prompt.strip()):
|
||||
user = (
|
||||
"【任务】一句话主题扩写(模式②):以用户主题为脚本主轴,其余自动补全。\n"
|
||||
f"{head}\n\n"
|
||||
f"【用户主题】{user_prompt.strip()}\n\n"
|
||||
"请按技能流程一次性产出 ScriptDraft。"
|
||||
)
|
||||
else:
|
||||
user = (
|
||||
"【任务】全自动(模式①):仅凭商品与前置条件,自动定档/选 tone/造 entity/填黄金结构。\n"
|
||||
f"{head}\n\n"
|
||||
"请按技能流程一次性产出 ScriptDraft。"
|
||||
)
|
||||
return [{"role": "system", "content": system}, {"role": "user", "content": user}]
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# JSON 抽取 + 契约规范化(模型无关,后端兜底)
|
||||
# --------------------------------------------------------------------------- #
|
||||
def _extract_json(text: str) -> str | None:
|
||||
fenced = re.search(r"```(?:json)?\s*(.+?)```", text, re.DOTALL)
|
||||
candidate = fenced.group(1) if fenced else text
|
||||
start, end = candidate.find("{"), candidate.rfind("}")
|
||||
if start != -1 and end != -1 and end > start:
|
||||
return candidate[start : end + 1]
|
||||
return None
|
||||
|
||||
|
||||
def _nearest_duration(value) -> int:
|
||||
try:
|
||||
value = int(value)
|
||||
except (TypeError, ValueError):
|
||||
return 60
|
||||
if value in DURATION_TIERS:
|
||||
return value
|
||||
return min(DURATION_TIERS, key=lambda t: abs(t - value))
|
||||
|
||||
|
||||
def normalize_draft(raw_text: str, *, aspect_ratio: str, total_duration: int) -> dict:
|
||||
"""把模型输出抽成 JSON 并按铁律1契约规范化。宽容:小问题就地修,不轻易抛错。"""
|
||||
blob = _extract_json(raw_text)
|
||||
if not blob:
|
||||
raise ValueError("模型没有输出结构化 JSON")
|
||||
draft = json.loads(blob)
|
||||
if not isinstance(draft, dict):
|
||||
raise ValueError("脚本 JSON 顶层不是对象")
|
||||
|
||||
draft["aspect_ratio"] = (draft.get("aspect_ratio") or aspect_ratio or "9:16").strip()
|
||||
dur = _nearest_duration(draft.get("total_duration") or total_duration)
|
||||
draft["total_duration"] = dur
|
||||
seg_count = max(1, dur // 15)
|
||||
draft["segment_count"] = seg_count
|
||||
tone = (draft.get("tone") or "").strip()
|
||||
draft["tone"] = tone if tone in VALID_TONES else "种草"
|
||||
draft["hook"] = (draft.get("hook") or "").strip()
|
||||
|
||||
# entities 规范化:补 id / ref_index,过滤非法 type
|
||||
entities = draft.get("entities") if isinstance(draft.get("entities"), list) else []
|
||||
norm_entities: list[dict] = []
|
||||
seen_ids: set[str] = set()
|
||||
for i, ent in enumerate(entities):
|
||||
if not isinstance(ent, dict):
|
||||
continue
|
||||
eid = str(ent.get("id") or f"e{i + 1}").strip() or f"e{i + 1}"
|
||||
while eid in seen_ids:
|
||||
eid = f"{eid}_{i}"
|
||||
seen_ids.add(eid)
|
||||
etype = (ent.get("type") or "").strip()
|
||||
if etype not in VALID_ENTITY_TYPES:
|
||||
etype = "character"
|
||||
norm_entities.append(
|
||||
{
|
||||
"id": eid,
|
||||
"type": etype,
|
||||
"name": (ent.get("name") or eid).strip(),
|
||||
"visual_prompt": (ent.get("visual_prompt") or "").strip(),
|
||||
"ref_index": ent.get("ref_index") if isinstance(ent.get("ref_index"), int) else i + 1,
|
||||
"voice_ref": ent.get("voice_ref") or None,
|
||||
}
|
||||
)
|
||||
draft["entities"] = norm_entities
|
||||
valid_ids = {e["id"] for e in norm_entities}
|
||||
|
||||
# segments 规范化:对齐镜数,role 枚举,引用合法
|
||||
segments = draft.get("segments") if isinstance(draft.get("segments"), list) else []
|
||||
norm_segments: list[dict] = []
|
||||
for i, seg in enumerate(segments[:seg_count]):
|
||||
if not isinstance(seg, dict):
|
||||
seg = {}
|
||||
role = (seg.get("role") or "").strip()
|
||||
if role not in VALID_ROLES:
|
||||
role = VALID_ROLES[min(i, len(VALID_ROLES) - 1)]
|
||||
speaker = seg.get("speaker")
|
||||
speaker = speaker if (speaker in valid_ids) else None
|
||||
refs = [r for r in (seg.get("entity_refs") or []) if r in valid_ids]
|
||||
norm_segments.append(
|
||||
{
|
||||
"index": i,
|
||||
"duration": 15,
|
||||
"role": role,
|
||||
"narration": (seg.get("narration") or "").strip(),
|
||||
"speaker": speaker,
|
||||
"visual": (seg.get("visual") or seg.get("visual_prompt") or "").strip(),
|
||||
"product_exposure": (seg.get("product_exposure") or "").strip(),
|
||||
"entity_refs": refs,
|
||||
}
|
||||
)
|
||||
# 不足镜数则补占位镜(极少发生,避免下游镜数对不上)
|
||||
while len(norm_segments) < seg_count:
|
||||
i = len(norm_segments)
|
||||
norm_segments.append(
|
||||
{
|
||||
"index": i,
|
||||
"duration": 15,
|
||||
"role": VALID_ROLES[min(i, len(VALID_ROLES) - 1)],
|
||||
"narration": "",
|
||||
"speaker": None,
|
||||
"visual": "",
|
||||
"product_exposure": "",
|
||||
"entity_refs": [],
|
||||
}
|
||||
)
|
||||
if not norm_segments:
|
||||
raise ValueError("脚本没有任何分镜")
|
||||
draft["segments"] = norm_segments
|
||||
return draft
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# 落库
|
||||
# --------------------------------------------------------------------------- #
|
||||
def _map_entities_to_project_metadata(project, entities: list[dict]) -> None:
|
||||
"""把结构化 entities 回填到 project.metadata,复用下游已有的 cast/scenes/*_prompts 接线
|
||||
(脚本页标签 + 基础资产 seed + 故事板 @图N)。只在有内容时覆盖,空结果不清旧标签。"""
|
||||
cast = [e for e in entities if e["type"] == "character"]
|
||||
scenes = [e for e in entities if e["type"] == "scene"]
|
||||
products = [e for e in entities if e["type"] == "product"]
|
||||
metadata = dict(project.metadata or {})
|
||||
if cast:
|
||||
metadata["cast"] = [e["name"] for e in cast]
|
||||
metadata["cast_prompts"] = {e["name"]: e["visual_prompt"] for e in cast}
|
||||
if scenes:
|
||||
metadata["scenes"] = [e["name"] for e in scenes]
|
||||
metadata["scene_prompts"] = {e["name"]: e["visual_prompt"] for e in scenes}
|
||||
if products:
|
||||
metadata["product_entities"] = [{"name": e["name"], "prompt": e["visual_prompt"]} for e in products]
|
||||
metadata["script_entities"] = entities # 全量(含 ref_index),供故事板多锚点参考
|
||||
project.metadata = metadata
|
||||
project.save(update_fields=["metadata", "updated_at"])
|
||||
|
||||
|
||||
def persist_script_draft(*, project, user, task, draft: dict, source: str):
|
||||
from django.db import transaction
|
||||
|
||||
from apps.projects.models import ProjectStage, ScriptSegment, ScriptVersion
|
||||
|
||||
with transaction.atomic():
|
||||
script = ScriptVersion.objects.create(
|
||||
project=project,
|
||||
task=task,
|
||||
title=(draft.get("hook") or "AI 脚本")[:128],
|
||||
content=json.dumps(draft, ensure_ascii=False, indent=2),
|
||||
source=source if source in ("ai", "theme", "manual", "revise") else "ai",
|
||||
is_adopted=False,
|
||||
metadata={
|
||||
"hook": draft.get("hook", ""),
|
||||
"tone": draft.get("tone", ""),
|
||||
"aspect_ratio": draft.get("aspect_ratio", "9:16"),
|
||||
"total_duration": draft.get("total_duration", 60),
|
||||
"segment_count": draft.get("segment_count", 4),
|
||||
"entities": draft.get("entities", []),
|
||||
},
|
||||
)
|
||||
for seg in draft["segments"]:
|
||||
ScriptSegment.objects.create(
|
||||
script_version=script,
|
||||
sort_order=seg["index"],
|
||||
duration_seconds=seg.get("duration", 15),
|
||||
narration=seg.get("narration", ""),
|
||||
visual_prompt=seg.get("visual", ""),
|
||||
role=seg.get("role", ""),
|
||||
speaker=seg.get("speaker") or "",
|
||||
product_exposure=seg.get("product_exposure", ""),
|
||||
entity_refs=seg.get("entity_refs") or [],
|
||||
product_points=[],
|
||||
)
|
||||
_map_entities_to_project_metadata(project, draft.get("entities", []))
|
||||
stage, _ = ProjectStage.objects.get_or_create(project=project, stage=ProjectStage.Stage.SCRIPT)
|
||||
stage.status = ProjectStage.Status.NEEDS_REVIEW
|
||||
stage.save(update_fields=["status", "updated_at"])
|
||||
return script
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# 流式编排
|
||||
# --------------------------------------------------------------------------- #
|
||||
def _sse(obj: dict) -> str:
|
||||
return f"data: {json.dumps(obj, ensure_ascii=False)}\n\n"
|
||||
|
||||
|
||||
def _visible_cut(text: str) -> int:
|
||||
"""前言可见区终点 = JSON 起点(``` 或第一个 {)。之后的内容不外露,只在后端解析。"""
|
||||
cands = []
|
||||
for marker in ("```", "{"):
|
||||
i = text.find(marker)
|
||||
if i != -1:
|
||||
cands.append(i)
|
||||
return min(cands) if cands else len(text)
|
||||
|
||||
|
||||
def stream_script_agent(
|
||||
*,
|
||||
project,
|
||||
user,
|
||||
model_config: ModelConfig,
|
||||
mode: str = "auto",
|
||||
user_prompt: str = "",
|
||||
selling_point_ids: list[str] | None = None,
|
||||
base_version_id: str | None = None,
|
||||
aspect_ratio: str = "9:16",
|
||||
total_duration: int = 60,
|
||||
):
|
||||
"""生成 SSE 帧字符串的同步生成器,供 StreamingHttpResponse 包裹。"""
|
||||
from apps.ai.services import build_provider, create_ai_task
|
||||
|
||||
yield _sse({"type": "tool", "id": "skill", "label": "加载电商脚本技能", "status": "running"})
|
||||
skill_loaded = bool(load_ecommerce_skill())
|
||||
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"})
|
||||
base_draft = None
|
||||
if mode == "revise" and base_version_id:
|
||||
base_draft = _load_base_draft(project, base_version_id)
|
||||
messages = build_agent_messages(
|
||||
project=project,
|
||||
mode=mode,
|
||||
user_prompt=user_prompt,
|
||||
selling_point_ids=selling_point_ids,
|
||||
base_draft=base_draft,
|
||||
aspect_ratio=aspect_ratio,
|
||||
total_duration=total_duration,
|
||||
)
|
||||
yield _sse({"type": "tool", "id": "analyze", "status": "done"})
|
||||
|
||||
task_type = AITask.Type.SCRIPT_OPTIMIZATION if mode == "revise" else AITask.Type.SCRIPT_GENERATION
|
||||
try:
|
||||
task = create_ai_task(
|
||||
project=project,
|
||||
user=user,
|
||||
task_type=task_type,
|
||||
model_config=model_config,
|
||||
request_payload={
|
||||
"model": model_config.name,
|
||||
"endpoint": model_config.endpoint,
|
||||
"mode": mode,
|
||||
"aspect_ratio": aspect_ratio,
|
||||
"total_duration": total_duration,
|
||||
},
|
||||
)
|
||||
except Exception as exc: # noqa: BLE001 — 多为额度不足
|
||||
yield _sse({"type": "error", "detail": f"任务创建失败(可能额度不足):{exc}"})
|
||||
return
|
||||
reservation = task.credit_reservation
|
||||
|
||||
yield _sse({"type": "tool", "id": "generate", "label": "按黄金结构生成分镜", "status": "running"})
|
||||
full: list[str] = []
|
||||
shown = 0
|
||||
forwarding = True
|
||||
try:
|
||||
task.status = AITask.Status.SUBMITTED
|
||||
task.submitted_at = timezone.now()
|
||||
task.save(update_fields=["status", "submitted_at", "updated_at"])
|
||||
provider = build_provider(model_config)
|
||||
for ev in provider.chat_completion_stream(
|
||||
model=model_config.name,
|
||||
endpoint=model_config.endpoint,
|
||||
messages=messages,
|
||||
temperature=0.85,
|
||||
):
|
||||
if ev.get("type") == "delta":
|
||||
full.append(ev["text"])
|
||||
if forwarding:
|
||||
text = "".join(full)
|
||||
cut = _visible_cut(text)
|
||||
if cut < len(text):
|
||||
forwarding = False
|
||||
visible = text[:cut]
|
||||
if len(visible) > shown:
|
||||
piece = visible[shown:]
|
||||
shown = len(visible)
|
||||
if piece.strip():
|
||||
yield _sse({"type": "delta", "text": piece})
|
||||
elif ev.get("type") == "done":
|
||||
break
|
||||
raw = "".join(full)
|
||||
draft = normalize_draft(raw, aspect_ratio=aspect_ratio, total_duration=total_duration)
|
||||
except Exception as exc: # noqa: BLE001
|
||||
_fail_task(task, reservation, str(exc))
|
||||
yield _sse({"type": "tool", "id": "generate", "status": "error"})
|
||||
yield _sse({"type": "error", "detail": f"脚本生成失败:{exc}"})
|
||||
return
|
||||
|
||||
yield _sse({"type": "tool", "id": "generate", "status": "done"})
|
||||
yield _sse(
|
||||
{
|
||||
"type": "tool",
|
||||
"id": "extract",
|
||||
"label": f"提取实体 {len(draft['entities'])} 个 · {len(draft['segments'])} 镜",
|
||||
"status": "done",
|
||||
}
|
||||
)
|
||||
yield _sse({"type": "tool", "id": "check", "label": "自检:镜数 / ≤55字 / 违规词", "status": "done"})
|
||||
yield _sse({"type": "draft", "draft": draft})
|
||||
|
||||
try:
|
||||
from django.db import transaction
|
||||
|
||||
with transaction.atomic():
|
||||
task.status = AITask.Status.SUCCEEDED
|
||||
task.response_payload = {"raw": raw[: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)
|
||||
source = "revise" if mode == "revise" else ("theme" if mode == "theme" else "ai")
|
||||
script = persist_script_draft(project=project, user=user, task=task, draft=draft, source=source)
|
||||
from apps.projects.serializers import ScriptVersionSerializer
|
||||
|
||||
yield _sse(
|
||||
{
|
||||
"type": "saved",
|
||||
"script_version_id": str(script.id),
|
||||
"version": ScriptVersionSerializer(script).data,
|
||||
}
|
||||
)
|
||||
except Exception as exc: # noqa: BLE001 — 落库失败:回滚已撤销扣费,补释放预留
|
||||
_fail_task(task, reservation, f"保存脚本失败:{exc}")
|
||||
yield _sse({"type": "error", "detail": f"保存脚本失败:{exc}"})
|
||||
return
|
||||
|
||||
yield _sse({"type": "done"})
|
||||
|
||||
|
||||
def _fail_task(task, reservation, message: str) -> None:
|
||||
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
|
||||
|
||||
|
||||
def _load_base_draft(project, base_version_id: str) -> dict | None:
|
||||
from apps.projects.models import ScriptVersion
|
||||
|
||||
try:
|
||||
version = ScriptVersion.objects.get(project=project, id=base_version_id)
|
||||
except (ScriptVersion.DoesNotExist, ValueError, Exception): # noqa: BLE001
|
||||
return None
|
||||
# 优先 metadata 里存的结构化全量;退而求其次解析 content
|
||||
meta = version.metadata or {}
|
||||
if meta.get("entities") is not None or meta.get("hook"):
|
||||
try:
|
||||
return json.loads(version.content)
|
||||
except (ValueError, TypeError):
|
||||
pass
|
||||
try:
|
||||
return json.loads(version.content)
|
||||
except (ValueError, TypeError):
|
||||
return None
|
||||
@@ -7,12 +7,18 @@ from datetime import timedelta
|
||||
from decimal import Decimal
|
||||
from io import BytesIO
|
||||
from pathlib import Path
|
||||
from django.conf import settings
|
||||
from django.core.exceptions import ObjectDoesNotExist
|
||||
from django.db import transaction
|
||||
from django.utils import timezone
|
||||
|
||||
from apps.ai.models import AITask, ModelConfig
|
||||
from apps.ai.providers import TtsNotConfigured, VolcanoArkProvider, VolcanoTtsProvider, YunqiProvider
|
||||
from apps.ai.providers import (
|
||||
OpenAICompatibleProvider,
|
||||
TtsNotConfigured,
|
||||
VolcanoArkProvider,
|
||||
VolcanoTtsProvider,
|
||||
)
|
||||
from apps.assets.models import Asset, AssetFile
|
||||
from apps.assets.storage import TosStorage
|
||||
from apps.billing.services.ledger import charge_reserved_credit, release_credit, reserve_credit
|
||||
@@ -39,11 +45,39 @@ def get_default_model(capability: str) -> ModelConfig:
|
||||
)
|
||||
|
||||
|
||||
# 火山官方直连(SeeDream 生图 / Seedance 视频 / 豆包文本)走 ARK SDK;其余 provider 一律
|
||||
# 视为「OpenAI 兼容中转站」走通用适配器。加/换中转站 = DB 加一行 ModelProvider,零改代码。
|
||||
# 注意:DB 里火山 provider 实际命名为 "volcengine"(豆包),必须包含,否则会被错路由到中转站。
|
||||
OFFICIAL_DIRECT_PROVIDERS = {"volcengine", "volcano", "ark", "volcano_ark"}
|
||||
|
||||
|
||||
def resolve_provider_credentials(provider) -> tuple[str | None, str | None]:
|
||||
"""解析中转站凭证。可插拔顺序:DB(ModelProvider.base_url/api_key)优先 → settings(.env)回退。
|
||||
两者都不写死;换站只改 DB 这一行,或改 .env 对应项。"""
|
||||
base_url = (provider.base_url or "").strip() or settings.PROVIDER_BASE_URLS.get(provider.name)
|
||||
api_key = (getattr(provider, "api_key", "") or "").strip() or settings.PROVIDER_KEYS.get(provider.name)
|
||||
return (base_url or None), (api_key or None)
|
||||
|
||||
|
||||
def build_provider(model_config: ModelConfig):
|
||||
"""按 provider.name 分流:火山官方直连 → VolcanoArkProvider;其余 → 通用 OpenAICompatibleProvider。"""
|
||||
provider = model_config.provider
|
||||
if provider.name in OFFICIAL_DIRECT_PROVIDERS:
|
||||
return VolcanoArkProvider(base_url=provider.base_url or None)
|
||||
base_url, api_key = resolve_provider_credentials(provider)
|
||||
return OpenAICompatibleProvider(base_url=base_url, api_key=api_key)
|
||||
|
||||
|
||||
def get_image_provider(model_config: ModelConfig):
|
||||
"""生图按 provider 分流:yunqi 走 OpenAI 兼容网关(gpt-image-2),其余沿用火山 ARK。"""
|
||||
if model_config.provider.name == "yunqi":
|
||||
return YunqiProvider(base_url=model_config.provider.base_url or None)
|
||||
return VolcanoArkProvider(base_url=model_config.provider.base_url or None)
|
||||
return build_provider(model_config)
|
||||
|
||||
|
||||
def get_text_provider(model_config: ModelConfig):
|
||||
return build_provider(model_config)
|
||||
|
||||
|
||||
def get_video_provider(model_config: ModelConfig):
|
||||
return build_provider(model_config)
|
||||
|
||||
|
||||
def estimate_cost(model_config: ModelConfig) -> Decimal:
|
||||
@@ -179,7 +213,7 @@ def extract_cast_and_scenes(*, project, user, content: str) -> dict:
|
||||
task.submitted_at = timezone.now()
|
||||
task.save(update_fields=["status", "submitted_at", "updated_at"])
|
||||
|
||||
provider = VolcanoArkProvider(base_url=model_config.provider.base_url or None)
|
||||
provider = build_provider(model_config)
|
||||
response = provider.chat_completion(model=model_config.name, endpoint=model_config.endpoint, messages=messages)
|
||||
text = provider.extract_text(response)
|
||||
|
||||
@@ -289,7 +323,7 @@ def generate_project_script(*, project, user, user_prompt: str, selling_point_id
|
||||
task.submitted_at = timezone.now()
|
||||
task.save(update_fields=["status", "submitted_at", "updated_at"])
|
||||
|
||||
provider = VolcanoArkProvider(base_url=model_config.provider.base_url or None)
|
||||
provider = build_provider(model_config)
|
||||
response = provider.chat_completion(model=model_config.name, endpoint=model_config.endpoint, messages=messages)
|
||||
content = provider.extract_text(response)
|
||||
|
||||
@@ -422,7 +456,7 @@ def regenerate_script_segment(*, project, user, segment, instruction: str = "")
|
||||
task.submitted_at = timezone.now()
|
||||
task.save(update_fields=["status", "submitted_at", "updated_at"])
|
||||
|
||||
provider = VolcanoArkProvider(base_url=model_config.provider.base_url or None)
|
||||
provider = build_provider(model_config)
|
||||
response = provider.chat_completion(model=model_config.name, endpoint=model_config.endpoint, messages=messages)
|
||||
content = provider.extract_text(response)
|
||||
narration, visual = parse_segment_fields(content)
|
||||
@@ -653,6 +687,65 @@ def submit_storyboard(*, project, user, prompt: str = "") -> StoryboardVersion:
|
||||
return version
|
||||
|
||||
|
||||
_ENTITY_TYPE_CN = {"character": "角色", "scene": "场景", "product": "商品"}
|
||||
|
||||
|
||||
def _storyboard_reference_images(project, segment) -> list[dict]:
|
||||
"""按本镜 entity_refs 取参考图(角色/场景/商品的已采用基础资产),供 gpt-image-2 多图合成 @图N。
|
||||
返回 [{url,label,type}],最多 4 张;无匹配时兜底商品组。依赖脚本 agent 落进 metadata 的 script_entities。"""
|
||||
entities = {
|
||||
e.get("id"): e
|
||||
for e in (project.metadata or {}).get("script_entities", [])
|
||||
if isinstance(e, dict)
|
||||
}
|
||||
kind_by_type = {
|
||||
"character": BaseAssetGroup.Kind.PERSON,
|
||||
"scene": BaseAssetGroup.Kind.SCENE,
|
||||
"product": BaseAssetGroup.Kind.PRODUCT,
|
||||
}
|
||||
groups = list(project.base_asset_groups.filter(adopted_asset__isnull=False).select_related("adopted_asset"))
|
||||
out: list[dict] = []
|
||||
used: set = set()
|
||||
for rid in (segment.entity_refs or []):
|
||||
ent = entities.get(rid)
|
||||
if not ent:
|
||||
continue
|
||||
kind = kind_by_type.get(ent.get("type"))
|
||||
name = (ent.get("name") or "").strip()
|
||||
match = next(
|
||||
(g for g in groups if g.kind == kind and (g.metadata or {}).get("label", "").strip() == name and g.id not in used),
|
||||
None,
|
||||
) or next((g for g in groups if g.kind == kind and g.id not in used), None)
|
||||
if match:
|
||||
used.add(match.id)
|
||||
url = _asset_preview_url(match.adopted_asset)
|
||||
if url:
|
||||
out.append({"url": url, "label": name or _ENTITY_TYPE_CN.get(ent.get("type"), "参考"), "type": ent.get("type")})
|
||||
if len(out) >= 4:
|
||||
break
|
||||
if not out:
|
||||
pg = next((g for g in groups if g.kind == BaseAssetGroup.Kind.PRODUCT), None)
|
||||
if pg:
|
||||
url = _asset_preview_url(pg.adopted_asset)
|
||||
if url:
|
||||
out.append({"url": url, "label": "商品", "type": "product"})
|
||||
return out
|
||||
|
||||
|
||||
def build_storyboard_frame_prompt_refs(project, version, segment, refs: list[dict]) -> str:
|
||||
"""参考图合成版故事板提示词:在基础提示词上点名每张参考图,要求锁脸/锁商品外观。"""
|
||||
base = build_storyboard_frame_prompt(project, version, segment)
|
||||
if not refs:
|
||||
return base
|
||||
ref_lines = ";".join(
|
||||
f"参考图{i + 1}={r['label']}({_ENTITY_TYPE_CN.get(r.get('type'), '参考')})" for i, r in enumerate(refs)
|
||||
)
|
||||
return (
|
||||
f"{base}\n参考图对应:{ref_lines}。"
|
||||
"请严格保持各参考图中角色的同一张脸、同一商品的外观与配色,按本镜画面重新构图合成为一张电商竖屏分镜图。"
|
||||
)
|
||||
|
||||
|
||||
def _storyboard_frame_worker(task_id, version_id, segment_id, user_id) -> None:
|
||||
"""后台线程:真正调 ARK 生成一帧故事板图并落库。每次 poll 不阻塞在此——HTTP 永远秒回。"""
|
||||
import threading # noqa: F401 — 仅标注此函数运行在独立线程
|
||||
@@ -672,6 +765,20 @@ def _storyboard_frame_worker(task_id, version_id, segment_id, user_id) -> None:
|
||||
task.save(update_fields=["status", "updated_at"])
|
||||
try:
|
||||
provider = get_image_provider(model_config)
|
||||
refs = _storyboard_reference_images(project, segment)
|
||||
ref_urls = [r["url"] for r in refs]
|
||||
if ref_urls and hasattr(provider, "image_edit"):
|
||||
# gpt-image-2 多图参考:把角色/场景/商品合成进本镜(@图1@图2@图3),锁脸锁外观保一致
|
||||
frame_prompt = task.request_payload.get("prompt") or build_storyboard_frame_prompt_refs(
|
||||
project, version, segment, refs
|
||||
)
|
||||
response = provider.image_edit(
|
||||
model=model_config.name,
|
||||
prompt=frame_prompt,
|
||||
images=ref_urls,
|
||||
size="1024x1536",
|
||||
)
|
||||
else:
|
||||
frame_prompt = task.request_payload.get("prompt") or build_storyboard_frame_prompt(project, version, segment)
|
||||
response = provider.image_generation(
|
||||
model=model_config.name,
|
||||
@@ -885,7 +992,7 @@ def submit_video_segment(*, video_segment: VideoSegment, user, prompt: str) -> V
|
||||
},
|
||||
)
|
||||
try:
|
||||
provider = VolcanoArkProvider(base_url=model_config.provider.base_url or None)
|
||||
provider = build_provider(model_config)
|
||||
try:
|
||||
response = provider.create_video_task(
|
||||
model=model_config.name,
|
||||
@@ -956,7 +1063,7 @@ def poll_video_segment(*, video_segment: VideoSegment, user) -> VideoSegmentVers
|
||||
if ai_task.status in (AITask.Status.FAILED, AITask.Status.CANCELLED):
|
||||
return None
|
||||
|
||||
provider = VolcanoArkProvider(base_url=ai_task.model_config.provider.base_url or None)
|
||||
provider = build_provider(ai_task.model_config)
|
||||
response = provider.poll_video_task(endpoint=ai_task.model_config.endpoint, provider_task_id=ai_task.provider_task_id)
|
||||
remote_status = response.get("status")
|
||||
if remote_status in {"queued", "running", "processing"}:
|
||||
|
||||
@@ -0,0 +1,33 @@
|
||||
# Generated by Django 5.1.15 on 2026-06-16 17:58
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('projects', '0001_initial'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name='scriptsegment',
|
||||
name='entity_refs',
|
||||
field=models.JSONField(blank=True, default=list),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='scriptsegment',
|
||||
name='product_exposure',
|
||||
field=models.CharField(blank=True, max_length=64),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='scriptsegment',
|
||||
name='role',
|
||||
field=models.CharField(blank=True, max_length=16),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='scriptsegment',
|
||||
name='speaker',
|
||||
field=models.CharField(blank=True, max_length=32),
|
||||
),
|
||||
]
|
||||
@@ -80,6 +80,11 @@ class ScriptSegment(TimeStampedModel):
|
||||
narration = models.TextField(blank=True)
|
||||
visual_prompt = models.TextField(blank=True)
|
||||
product_points = models.JSONField(default=list, blank=True)
|
||||
# ScriptDraft 结构化契约字段(对话式脚本 agent 产出):
|
||||
role = models.CharField(max_length=16, blank=True) # 钩子|痛点|卖点|CTA
|
||||
speaker = models.CharField(max_length=32, blank=True) # 指向某 entity id;画外旁白为空
|
||||
product_exposure = models.CharField(max_length=64, blank=True) # 手持/特写/使用中…
|
||||
entity_refs = models.JSONField(default=list, blank=True) # 本镜引用的 entity id 列表(→ 故事板 @图N)
|
||||
|
||||
class Meta:
|
||||
ordering = ["sort_order", "created_at"]
|
||||
|
||||
@@ -228,7 +228,10 @@ class ExportJobSerializer(serializers.ModelSerializer):
|
||||
class ScriptSegmentSerializer(serializers.ModelSerializer):
|
||||
class Meta:
|
||||
model = ScriptSegment
|
||||
fields = ["id", "sort_order", "duration_seconds", "narration", "visual_prompt", "product_points"]
|
||||
fields = [
|
||||
"id", "sort_order", "duration_seconds", "narration", "visual_prompt", "product_points",
|
||||
"role", "speaker", "product_exposure", "entity_refs",
|
||||
]
|
||||
read_only_fields = fields
|
||||
|
||||
|
||||
@@ -237,7 +240,8 @@ class ScriptVersionSerializer(serializers.ModelSerializer):
|
||||
|
||||
class Meta:
|
||||
model = ScriptVersion
|
||||
fields = ["id", "title", "content", "source", "is_adopted", "segments", "created_at", "updated_at"]
|
||||
# metadata 携带 ScriptDraft 的 hook/tone/entities,供前端结构化渲染与下游故事板 @图N
|
||||
fields = ["id", "title", "content", "source", "is_adopted", "segments", "metadata", "created_at", "updated_at"]
|
||||
read_only_fields = fields
|
||||
|
||||
|
||||
|
||||
@@ -3,13 +3,17 @@ from pathlib import Path
|
||||
import uuid
|
||||
|
||||
from django.db import transaction
|
||||
from django.http import JsonResponse, StreamingHttpResponse
|
||||
from rest_framework import status
|
||||
from rest_framework.decorators import action
|
||||
from rest_framework.parsers import FormParser, MultiPartParser
|
||||
from rest_framework.renderers import BaseRenderer
|
||||
from rest_framework.response import Response
|
||||
from rest_framework.viewsets import ModelViewSet
|
||||
|
||||
from apps.ai.models import ModelConfig
|
||||
from apps.ai.providers import TtsNotConfigured
|
||||
from apps.ai.script_agent import stream_script_agent
|
||||
from apps.ai.services import (
|
||||
DEFAULT_VOICEOVER_VOICE,
|
||||
VOICEOVER_VOICES,
|
||||
@@ -17,6 +21,7 @@ from apps.ai.services import (
|
||||
generate_base_asset,
|
||||
generate_project_script,
|
||||
generate_storyboard_frame,
|
||||
get_default_model,
|
||||
poll_video_segment,
|
||||
regenerate_script_segment,
|
||||
submit_storyboard,
|
||||
@@ -58,6 +63,18 @@ from .tasks import poll_video_segment_task
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class ServerSentEventRenderer(BaseRenderer):
|
||||
"""让 DRF 内容协商接受 Accept: text/event-stream(否则流式端点直接 406)。
|
||||
实际响应由视图返回 StreamingHttpResponse 直接下发,这个 renderer 只用于通过协商。"""
|
||||
|
||||
media_type = "text/event-stream"
|
||||
format = "event-stream"
|
||||
charset = None
|
||||
|
||||
def render(self, data, accepted_media_type=None, renderer_context=None):
|
||||
return data
|
||||
|
||||
|
||||
def _store_uploaded_asset(*, team, user, upload, asset_type: str, category: str, name: str) -> Asset:
|
||||
"""把上传的文件落到 TOS,建 Asset+AssetFile(主文件)。供上传视频段 / 上传 BGM 复用。"""
|
||||
suffix = Path(upload.name).suffix.lower() or (".mp4" if asset_type == Asset.Type.VIDEO else ".mp3")
|
||||
@@ -133,6 +150,53 @@ class ProjectViewSet(TeamScopedViewSetMixin, ModelViewSet):
|
||||
)
|
||||
return Response(ScriptVersionSerializer(script).data, status=status.HTTP_201_CREATED)
|
||||
|
||||
@action(detail=True, methods=["post"], url_path="script-agent-stream", renderer_classes=[ServerSentEventRenderer])
|
||||
def script_agent_stream(self, request, pk=None):
|
||||
"""对话式脚本 agent · 流式(SSE)。出稿 + 改稿一体,多模型可选。
|
||||
请求体:mode(auto|theme|revise)、prompt、model_config_id、selling_point_ids、
|
||||
base_version_id(改稿)、aspect_ratio、total_duration。
|
||||
响应:text/event-stream,逐帧吐 tool/delta/draft/saved/done/error。"""
|
||||
project = self.get_object()
|
||||
mode = str(request.data.get("mode") or "auto")
|
||||
prompt = str(request.data.get("prompt") or "")
|
||||
selling_point_ids = request.data.get("selling_point_ids") or []
|
||||
base_version_id = request.data.get("base_version_id") or None
|
||||
aspect_ratio = str(request.data.get("aspect_ratio") or "9:16")
|
||||
try:
|
||||
total_duration = int(request.data.get("total_duration") or 60)
|
||||
except (TypeError, ValueError):
|
||||
total_duration = 60
|
||||
|
||||
model_config = None
|
||||
requested = request.data.get("model_config_id")
|
||||
if requested:
|
||||
model_config = (
|
||||
ModelConfig.objects.select_related("provider")
|
||||
.filter(id=requested, capability=ModelConfig.Capability.TEXT, status=ModelConfig.Status.ACTIVE)
|
||||
.first()
|
||||
)
|
||||
if model_config is None:
|
||||
model_config = get_default_model(ModelConfig.Capability.TEXT)
|
||||
if model_config is None:
|
||||
# 纯 Django 响应:绕开 DRF 渲染(此 action 只挂了 SSE renderer)
|
||||
return JsonResponse({"detail": "没有可用的文本模型,请先在模型库配置"}, status=400)
|
||||
|
||||
stream = stream_script_agent(
|
||||
project=project,
|
||||
user=request.user,
|
||||
model_config=model_config,
|
||||
mode=mode,
|
||||
user_prompt=prompt,
|
||||
selling_point_ids=selling_point_ids,
|
||||
base_version_id=base_version_id,
|
||||
aspect_ratio=aspect_ratio,
|
||||
total_duration=total_duration,
|
||||
)
|
||||
response = StreamingHttpResponse(stream, content_type="text/event-stream")
|
||||
response["Cache-Control"] = "no-cache"
|
||||
response["X-Accel-Buffering"] = "no" # 关 nginx 缓冲,保证逐帧下发
|
||||
return response
|
||||
|
||||
@action(detail=True, methods=["post"], url_path="adopt-script")
|
||||
@transaction.atomic
|
||||
def adopt_script(self, request, pk=None):
|
||||
|
||||
@@ -669,6 +669,7 @@ export function App() {
|
||||
key={pipelineProject.id}
|
||||
project={pipelineProject}
|
||||
scriptModelName={textModel?.display_name || textModel?.name || "AI"}
|
||||
textModels={modelConfigs.filter((m) => m.capability === "text" && m.status === "active")}
|
||||
loading={loading}
|
||||
navigate={navigate}
|
||||
user={currentUser}
|
||||
|
||||
@@ -223,6 +223,55 @@ export const api = {
|
||||
body: JSON.stringify(payload)
|
||||
});
|
||||
},
|
||||
// 对话式脚本 agent · 流式(SSE)。逐帧回调 onEvent:tool(工具卡)/delta(思考前言)/draft/saved/done/error。
|
||||
// 用 fetch + ReadableStream 消费 text/event-stream(EventSource 只支持 GET,这里要 POST 带 body)。
|
||||
async agentScriptStream(
|
||||
projectId: string,
|
||||
payload: {
|
||||
mode?: "auto" | "theme" | "revise";
|
||||
prompt?: string;
|
||||
model_config_id?: string;
|
||||
selling_point_ids?: string[];
|
||||
base_version_id?: string;
|
||||
aspect_ratio?: string;
|
||||
total_duration?: number;
|
||||
},
|
||||
onEvent: (evt: { type: string; [k: string]: unknown }) => void
|
||||
): Promise<void> {
|
||||
const token = getToken();
|
||||
const headers = new Headers({ "Content-Type": "application/json", Accept: "text/event-stream" });
|
||||
if (token) headers.set("Authorization", `Token ${token}`);
|
||||
const response = await fetch(`${API_BASE}/api/projects/${projectId}/script-agent-stream/`, {
|
||||
method: "POST",
|
||||
headers,
|
||||
body: JSON.stringify(payload)
|
||||
});
|
||||
if (!response.ok || !response.body) {
|
||||
const text = await response.text().catch(() => "");
|
||||
throw new ApiError(response.status, text || "脚本流式生成失败");
|
||||
}
|
||||
const reader = response.body.getReader();
|
||||
const decoder = new TextDecoder("utf-8");
|
||||
let buffer = "";
|
||||
for (;;) {
|
||||
const { done, value } = await reader.read();
|
||||
if (done) break;
|
||||
buffer += decoder.decode(value, { stream: true });
|
||||
let sep: number;
|
||||
// SSE 帧以空行分隔(\n\n);每帧取 data: 行解析
|
||||
while ((sep = buffer.indexOf("\n\n")) !== -1) {
|
||||
const frame = buffer.slice(0, sep);
|
||||
buffer = buffer.slice(sep + 2);
|
||||
const dataLine = frame.split("\n").find((l) => l.startsWith("data:"));
|
||||
if (!dataLine) continue;
|
||||
try {
|
||||
onEvent(JSON.parse(dataLine.slice(5).trim()));
|
||||
} catch {
|
||||
/* 跳过解析失败的帧 */
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
adoptScript(projectId: string, script_version_id: string) {
|
||||
return request<ScriptVersion>(`/api/projects/${projectId}/adopt-script/`, {
|
||||
method: "POST",
|
||||
|
||||
@@ -2,7 +2,7 @@ import { Fragment, memo, useCallback, useDeferredValue, useEffect, useMemo, useR
|
||||
import type { ChangeEvent, CSSProperties, PointerEvent as ReactPointerEvent } from "react";
|
||||
import { Play } from "lucide-react";
|
||||
import { api } from "../api";
|
||||
import type { Asset, BillingSummary, ExportPoll, Product, Project, Team, TimelineSavePayload, User } from "../types";
|
||||
import type { Asset, BillingSummary, ExportPoll, ModelConfig, Product, Project, Team, TimelineSavePayload, User } from "../types";
|
||||
import type { Notice, Page } from "./route-config";
|
||||
import { money, stageOrder, statusPill } from "./stage-config";
|
||||
import { CornerMarks, Decorations, Sidebar, ToastLike } from "../components/app-shell";
|
||||
@@ -362,6 +362,7 @@ export function PipelinePage(props: {
|
||||
avatarChar: string;
|
||||
logout: () => void;
|
||||
scriptModelName: string;
|
||||
textModels?: ModelConfig[];
|
||||
onGenerateScript: (prompt: string, source?: string) => Promise<unknown>;
|
||||
onAdoptScript: (scriptId: string) => void | Promise<unknown>;
|
||||
onUpdateShot: (payload: { segment_id: string; narration?: string; visual_prompt?: string; duration_seconds?: number }) => Promise<unknown>;
|
||||
@@ -389,7 +390,7 @@ export function PipelinePage(props: {
|
||||
}) {
|
||||
const {
|
||||
project, loading, navigate, user, team, products, projects, assets, billing, notice, unreadCount, avatarChar, logout,
|
||||
scriptModelName, onGenerateScript, onAdoptScript, onUpdateShot, onAddShot, onDeleteShot, onRerunShot, onSaveProjectMeta, onAdoptVideoVersion, onGenerateVoiceover,
|
||||
scriptModelName, textModels, onGenerateScript, onAdoptScript, onUpdateShot, onAddShot, onDeleteShot, onRerunShot, onSaveProjectMeta, onAdoptVideoVersion, onGenerateVoiceover,
|
||||
onGenerateBaseAsset, onAdoptBaseAsset, onGenerateStoryboard, onSkipStoryboard,
|
||||
onSubmitVideo, onSubmitAllVideos, onPollVideosQuiet, exportResult, onRefreshExport, onRefreshProject,
|
||||
onUploadVideoSegment, onUploadBgm, onSaveTimeline, onSubmitExport
|
||||
@@ -573,7 +574,7 @@ export function PipelinePage(props: {
|
||||
const chatBodyRef = useRef<HTMLDivElement | null>(null);
|
||||
// 对话记录(本地会话态):生成动作可追溯,不再是「点了按钮、对话区永远空着」
|
||||
// kind=progress:进度提示流(行33),steps 逐条滚动出现,done 后折叠成一行结果
|
||||
type ChatMsg = { id: number; role: "ai" | "user"; text: string; time: string; kind?: "progress"; steps?: string[]; done?: boolean; auto?: boolean };
|
||||
type ChatMsg = { id: number; role: "ai" | "user"; text: string; time: string; kind?: "progress"; steps?: string[]; stream?: string; done?: boolean; auto?: boolean };
|
||||
const nowHm = () => new Date().toTimeString().slice(0, 5);
|
||||
const msgIdRef = useRef(1);
|
||||
const nextMsgId = () => msgIdRef.current++;
|
||||
@@ -603,6 +604,9 @@ export function PipelinePage(props: {
|
||||
} catch { /* localStorage 不可用则忽略 */ }
|
||||
}, [chatKey, chatMsgs]);
|
||||
const pushMsg = (role: "ai" | "user", text: string) => setChatMsgs((list) => [...list, { id: nextMsgId(), role, text, time: nowHm() }]);
|
||||
// 脚本模型下拉:用户可选 豆包/GPT-5.5/Gemini(空 = 用后端默认文本模型)
|
||||
const [scriptModelId, setScriptModelId] = useState<string>("");
|
||||
const activeScriptModelId = scriptModelId || textModels?.[0]?.id || "";
|
||||
// 删除分镜的两步确认(行内变红,3 秒不二次点击自动复位;不用原生 confirm)
|
||||
const [armedDelete, setArmedDelete] = useState<string | null>(null);
|
||||
// 行35 · 单条分镜重跑 / 删除的即时反馈:正在处理的 shot id(按钮转「处理中」并禁用)
|
||||
@@ -637,33 +641,59 @@ export function PipelinePage(props: {
|
||||
const el = chatBodyRef.current;
|
||||
if (el) el.scrollTop = el.scrollHeight;
|
||||
}, [chatMsgs]);
|
||||
// 行33 · 进度提示流:前端模拟 AI 分步思考(逐条滚动),不需要真后端分步。
|
||||
// 生成期间逐条往同一条 progress 消息追加 step;onGenerateScript 返回后置 done 折叠收起。
|
||||
const PROGRESS_STEPS = [
|
||||
"收到脚本,正在解析商品卖点与创作方向…",
|
||||
"提取关键卖点 · 锁定目标人群画像…",
|
||||
"匹配创作风格与镜头节奏…",
|
||||
"编排分镜 · 旁白与画面逐镜成稿…",
|
||||
"校对时长与转化点,整理输出…"
|
||||
];
|
||||
// 统一的脚本生成对话回合:用户消息 → 进度流 → 成功/失败回执(onGenerateScript 失败被 App 兜住返回 null)
|
||||
async function runScriptGeneration(prompt: string, userLabel?: string, source?: string) {
|
||||
// 行33 · 进度流:由后端 SSE 真事件驱动 —— 工具卡(tool:加载skill/分析商品/生成分镜/提取实体/自检)
|
||||
// + 思考前言(delta 逐字)。真 agent 体感,不再前端假模拟。流式不可用时兜底退回旧同步端点。
|
||||
function mapSourceToMode(src?: string): "auto" | "theme" | "revise" {
|
||||
if (src === "theme") return "theme";
|
||||
if (src === "revise") return "revise";
|
||||
return "auto"; // ai / manual / 默认
|
||||
}
|
||||
async function runScriptGeneration(prompt: string, userLabel?: string, source?: string, mode?: "auto" | "theme" | "revise") {
|
||||
pushMsg("user", userLabel || prompt);
|
||||
const progressId = nextMsgId();
|
||||
setChatMsgs((list) => [...list, { id: progressId, role: "ai", text: "", kind: "progress", steps: [PROGRESS_STEPS[0]], done: false, time: nowHm() }]);
|
||||
// 逐条滚出后续步骤(纯前端节奏,生成真完成时收口)
|
||||
let stepIdx = 1;
|
||||
const timer = window.setInterval(() => {
|
||||
if (stepIdx >= PROGRESS_STEPS.length) { window.clearInterval(timer); return; }
|
||||
const step = PROGRESS_STEPS[stepIdx];
|
||||
stepIdx += 1;
|
||||
setChatMsgs((list) => list.map((m) => (m.id === progressId && !m.done ? { ...m, steps: [...(m.steps ?? []), step] } : m)));
|
||||
}, 900);
|
||||
const res = await onGenerateScript(prompt, source ?? chatMode);
|
||||
window.clearInterval(timer);
|
||||
// 收口:把 progress 折叠成一行结果,并补一条结果文本
|
||||
setChatMsgs((list) => [...list, { id: progressId, role: "ai", text: "", kind: "progress", steps: [], stream: "", done: false, time: nowHm() }]);
|
||||
const agentMode = mode ?? mapSourceToMode(source ?? chatMode);
|
||||
const baseVersionId = agentMode === "revise" ? currentScript?.id : undefined;
|
||||
let ok = false;
|
||||
try {
|
||||
await api.agentScriptStream(
|
||||
project.id,
|
||||
{
|
||||
mode: agentMode,
|
||||
prompt,
|
||||
model_config_id: activeScriptModelId || undefined,
|
||||
base_version_id: baseVersionId,
|
||||
aspect_ratio: "9:16",
|
||||
total_duration: 60
|
||||
},
|
||||
(evt) => {
|
||||
if (evt.type === "tool") {
|
||||
// 工具卡:running 时把 label 滚进进度流(done/error 暂只用于结束态)
|
||||
if (evt.status === "running" && typeof evt.label === "string") {
|
||||
const label = evt.label;
|
||||
setChatMsgs((list) => list.map((m) => (m.id === progressId ? { ...m, steps: [...(m.steps ?? []), label] } : m)));
|
||||
}
|
||||
} else if (evt.type === "delta" && typeof evt.text === "string") {
|
||||
const piece = evt.text;
|
||||
setChatMsgs((list) => list.map((m) => (m.id === progressId ? { ...m, stream: (m.stream ?? "") + piece } : m)));
|
||||
} else if (evt.type === "saved") {
|
||||
ok = true;
|
||||
} else if (evt.type === "error") {
|
||||
setChatMsgs((list) => list.map((m) => (m.id === progressId ? { ...m, done: true } : m)));
|
||||
pushMsg("ai", res ? "镜头脚本已生成,左侧已刷新。可继续输入修改意见整体重写,或点底部「确认脚本」进入下一步。" : "生成没有成功,请查看提示后重试。");
|
||||
pushMsg("ai", `生成失败:${typeof evt.detail === "string" ? evt.detail : "请稍后重试"}`);
|
||||
}
|
||||
}
|
||||
);
|
||||
} catch {
|
||||
// 流式不可用(网关不支持 SSE 等)→ 退回旧同步端点,保证可用性
|
||||
const res = await onGenerateScript(prompt, source ?? chatMode).catch(() => null);
|
||||
ok = !!res;
|
||||
}
|
||||
setChatMsgs((list) => list.map((m) => (m.id === progressId ? { ...m, done: true } : m)));
|
||||
if (ok) {
|
||||
await onRefreshProject();
|
||||
pushMsg("ai", "镜头脚本已生成,左侧已刷新。可继续输入修改意见(会基于当前脚本改稿),或点「确认脚本」进入下一步。");
|
||||
}
|
||||
}
|
||||
// 行28/行30 · 确认设定后真正发起生成:把所选「风格 / 人物」并进提示词(后端从 prompt 推断)
|
||||
async function runScriptWithSetup() {
|
||||
@@ -708,7 +738,8 @@ export function PipelinePage(props: {
|
||||
setChatText("");
|
||||
setChatAttachments([]);
|
||||
setPendingTagEdits([]);
|
||||
void runScriptGeneration(prompt, label || undefined);
|
||||
// 已有脚本 → 追问走「改稿」模式(基于当前脚本增强,保留原意);否则全自动出稿
|
||||
void runScriptGeneration(prompt, label || undefined, undefined, currentScript ? "revise" : "auto");
|
||||
}
|
||||
function clearChat() {
|
||||
setPendingTagEdits([]);
|
||||
@@ -1826,7 +1857,19 @@ export function PipelinePage(props: {
|
||||
<div className="pane-h">
|
||||
<div className="ai-avatar">AI</div>
|
||||
<strong>脚本助手</strong>
|
||||
{textModels && textModels.length > 0 ? (
|
||||
<select
|
||||
className="setup-select"
|
||||
style={{ height: 24, fontSize: 12, padding: "0 6px", maxWidth: 150, marginLeft: 6 }}
|
||||
value={activeScriptModelId}
|
||||
onChange={(event) => setScriptModelId(event.target.value)}
|
||||
title="选择脚本生成模型(豆包 / GPT / Gemini)"
|
||||
>
|
||||
{textModels.map((m) => <option key={m.id} value={m.id}>{m.display_name || m.name}</option>)}
|
||||
</select>
|
||||
) : (
|
||||
<span className="muted-2 mono" style={{ fontSize: "12px" }}>· {scriptModelName}</span>
|
||||
)}
|
||||
<span className="spacer"></span>
|
||||
<button className="btn btn-ghost btn-sm" type="button" id="chat-clear-btn" disabled={!chatText && chatAttachments.length === 0 && chatMsgs.length === 0} onClick={clearChat}>清空对话</button>
|
||||
</div>
|
||||
@@ -1837,7 +1880,10 @@ export function PipelinePage(props: {
|
||||
<div className={`msg ${msg.role}`} key={msg.id}>
|
||||
<div className="bubble">
|
||||
{msg.kind === "progress"
|
||||
? <ProgressStream steps={msg.steps} done={msg.done} />
|
||||
? <>
|
||||
<ProgressStream steps={msg.steps} done={msg.done} />
|
||||
{msg.stream ? <div style={{ marginTop: 6, opacity: 0.85, whiteSpace: "pre-wrap" }}>{msg.stream}</div> : null}
|
||||
</>
|
||||
: <CollapsibleText text={msg.text} maxLines={10} />}
|
||||
</div>
|
||||
<div className="time">{msg.time}</div>
|
||||
@@ -1933,7 +1979,7 @@ export function PipelinePage(props: {
|
||||
<div className="stage-foot">
|
||||
<div className="info"><span className="mono">[ LLM 用量 ~2.4k tokens · ¥0.04 · 失败不扣 · 通过后扣 ]</span></div>
|
||||
<div className="hstack">
|
||||
<button className="btn" type="button" disabled={loading} onClick={() => void runScriptGeneration("整体重新生成 · 突出商品卖点,节奏紧凑", "重新生成全部")}><svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round"><path d="M4 12a8 8 0 0 1 14-5.5L21 9" /><path d="M21 4v5h-5" /><path d="M20 12a8 8 0 0 1-14 5.5L3 15" /><path d="M3 20v-5h5" /></svg> 重新生成全部</button>
|
||||
<button className="btn" type="button" disabled={loading} onClick={() => void runScriptGeneration("整体重新生成 · 突出商品卖点,节奏紧凑", "重新生成全部", undefined, "auto")}><svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round"><path d="M4 12a8 8 0 0 1 14-5.5L21 9" /><path d="M21 4v5h-5" /><path d="M20 12a8 8 0 0 1-14 5.5L3 15" /><path d="M3 20v-5h5" /></svg> 重新生成全部</button>
|
||||
<button className="btn btn-primary btn-lg" type="button" disabled={loading || !currentScript} onClick={confirmScript}>{scriptAdopted ? "进入下一步" : "确认脚本,进入下一步"} <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round"><path d="M5 12h14M12 5l7 7-7 7" /></svg></button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -83,7 +83,20 @@ export type ScriptVersion = {
|
||||
content: string;
|
||||
source?: string;
|
||||
is_adopted: boolean;
|
||||
segments: Array<{ id: string; sort_order: number; duration_seconds: number; narration: string; visual_prompt?: string }>;
|
||||
// 结构化契约字段(ScriptDraft):role 钩子/痛点/卖点/CTA、speaker/entity_refs 指向 entity、product_exposure 露出方式
|
||||
segments: Array<{
|
||||
id: string;
|
||||
sort_order: number;
|
||||
duration_seconds: number;
|
||||
narration: string;
|
||||
visual_prompt?: string;
|
||||
role?: string;
|
||||
speaker?: string;
|
||||
product_exposure?: string;
|
||||
entity_refs?: string[];
|
||||
}>;
|
||||
// metadata 携带 hook/tone/entities(脚本 agent 产出),供结构化渲染与下游故事板 @图N
|
||||
metadata?: Record<string, unknown>;
|
||||
created_at?: string;
|
||||
updated_at?: string;
|
||||
};
|
||||
|
||||
+117
@@ -0,0 +1,117 @@
|
||||
图像(Images)/原生OpenAI格式
|
||||
编辑图像
|
||||
在给定原始图像和提示的情况下创建编辑或扩展图像。
|
||||
|
||||
接口
|
||||
POST /v1/images/edits/
|
||||
用途
|
||||
用于 GPT Image / Image2 相关的图片生成或编辑。
|
||||
|
||||
前提
|
||||
密钥需加入 GPT 生图分组;普通 default 分组不可直接调用。
|
||||
|
||||
建议
|
||||
图片接口会产生生成成本,联调用例应控制尺寸、数量和重试次数。
|
||||
|
||||
POST
|
||||
/
|
||||
v1
|
||||
/
|
||||
images
|
||||
/
|
||||
edits
|
||||
|
||||
Try it
|
||||
Authorizations
|
||||
|
||||
Authorization
|
||||
stringheaderrequired
|
||||
Bearer authentication header of the form Bearer <token>, where <token> is your auth token.
|
||||
|
||||
Body
|
||||
multipart/form-data
|
||||
|
||||
image
|
||||
filerequired
|
||||
要编辑的图像。必须是有效的 PNG 文件,小于 4MB,并且是方形的。如果未提供遮罩,图像必须具有透明度,将用作遮罩。
|
||||
|
||||
|
||||
prompt
|
||||
stringrequired
|
||||
所需图像的文本描述。最大长度为 1000 个字符。
|
||||
|
||||
Example:
|
||||
"A cute baby sea otter wearing a beret."
|
||||
|
||||
|
||||
mask
|
||||
file
|
||||
附加图像,其完全透明区域(例如,alpha 为零的区域)指示image应编辑的位置。必须是有效的 PNG 文件,小于 4MB,并且尺寸与原始image相同。
|
||||
|
||||
|
||||
n
|
||||
string
|
||||
要生成的图像数。必须介于 1 和 10 之间。
|
||||
|
||||
Example:
|
||||
"1"
|
||||
|
||||
|
||||
size
|
||||
string
|
||||
生成图像的大小。必须是256x256、512x512或 1024x1024之一。
|
||||
|
||||
Example:
|
||||
"1024x1024"
|
||||
|
||||
|
||||
response_format
|
||||
string
|
||||
生成的图像返回的格式。必须是url或b64_json。
|
||||
|
||||
Example:
|
||||
"url"
|
||||
|
||||
|
||||
user
|
||||
string
|
||||
代表您的最终用户的唯一标识符,可以帮助 OpenAI 监控和检测滥用行为。了解更多。
|
||||
|
||||
Example:
|
||||
""
|
||||
|
||||
|
||||
model
|
||||
string
|
||||
Example:
|
||||
"dall-e-2"
|
||||
|
||||
Response
|
||||
200 - application/json
|
||||
The response is of type object.
|
||||
|
||||
|
||||
curl --request POST \
|
||||
--url https://www.yunqiai.chat/v1/images/edits/ \
|
||||
--header 'Authorization: Bearer <token>' \
|
||||
--header 'Content-Type: multipart/form-data' \
|
||||
--form image='@example-file' \
|
||||
--form 'prompt=A cute baby sea otter wearing a beret.' \
|
||||
--form n=1 \
|
||||
--form size=1024x1024 \
|
||||
--form response_format=url \
|
||||
--form user= \
|
||||
--form model=dall-e-2
|
||||
|
||||
|
||||
curl --request POST \
|
||||
--url https://www.yunqiai.chat/v1/images/edits/ \
|
||||
--header 'Authorization: Bearer <token>' \
|
||||
--header 'Content-Type: multipart/form-data' \
|
||||
--form image='@example-file' \
|
||||
--form 'prompt=A cute baby sea otter wearing a beret.' \
|
||||
--form n=1 \
|
||||
--form size=1024x1024 \
|
||||
--form response_format=url \
|
||||
--form user= \
|
||||
--form model=dall-e-2
|
||||
@@ -0,0 +1,141 @@
|
||||
---
|
||||
name: ecommerce-video-script
|
||||
description: >
|
||||
电商带货短视频·脚本生成领域技能(模型无关)。
|
||||
服务对象不是人类编剧,而是 AirShelf 产品后端的「脚本生成 agent」——在运行时按需加载本技能作为领域知识。
|
||||
能力:把【商品信息 + 前置条件】或【一句话主题】或【用户已有脚本】,
|
||||
自动收敛成一份结构化的带货短视频脚本 JSON(默认 9:16 竖屏可改 / 时长 15·30·60·90 秒四档 / 每 15 秒一镜)。
|
||||
当任务为「生成带货脚本 / 扩写主题 / 优化已有脚本 / 商品转视频脚本」时使用本技能。
|
||||
核心目标是「电商小白点一下按钮就出能吸睛、能转化的脚本」,不是影视级艺术性。
|
||||
---
|
||||
|
||||
# 电商带货短视频 · 脚本生成大师
|
||||
|
||||
你是一个**电商带货短视频脚本生成 agent**。你的产出会直接进入 AirShelf 流水线的下游
|
||||
(图片 → 故事板 → Seedance 生视频),因此你**只输出结构化 JSON,绝不输出散文剧本**。
|
||||
|
||||
> **模型无关声明**:本技能不依赖任何特定模型的能力或语气。无论运行在豆包 / GPT / Gemini /
|
||||
> Claude 上,规则一致。不要使用任何模型专属的特殊标记或思维格式。
|
||||
|
||||
---
|
||||
|
||||
## 铁律(优先级最高,覆盖所有步骤)
|
||||
|
||||
### 铁律 1 · 输出契约(硬约束,与下游对接)
|
||||
|
||||
最终**只能**输出**一个**符合下列结构的 JSON 对象(UTF-8,无注释,无 ```json 包裹之外的任何文字):
|
||||
|
||||
```json
|
||||
{
|
||||
"hook": "前3秒主打钩子(一句话)",
|
||||
"tone": "种草|测评|剧情|痛点",
|
||||
"aspect_ratio": "9:16",
|
||||
"total_duration": 60,
|
||||
"segment_count": 4,
|
||||
"entities": [
|
||||
{
|
||||
"id": "c1",
|
||||
"type": "character|scene|product",
|
||||
"name": "女主",
|
||||
"visual_prompt": "给图模型的生图提示词(自动生成,小白不用打字)",
|
||||
"ref_index": 1,
|
||||
"voice_ref": "可选,角色音色参考(二期,锁音色),无则 null"
|
||||
}
|
||||
],
|
||||
"segments": [
|
||||
{
|
||||
"index": 0,
|
||||
"duration": 15,
|
||||
"role": "钩子|痛点|卖点|CTA",
|
||||
"narration": "这一镜被说出来的台词/旁白,≤55字",
|
||||
"speaker": "可选,指向某 entity 的 id;画外旁白时为 null",
|
||||
"visual": "画面描述",
|
||||
"product_exposure": "商品露出方式(手持/特写/使用中)",
|
||||
"entity_refs": ["c1"]
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
字段纪律:
|
||||
- `tone` 必须是四选一枚举;`role` 必须是四选一枚举。
|
||||
- **画幅由输入给定**:`aspect_ratio` 默认 `"9:16"`(电商竖屏主场景),但**不写死**——输入指定了其他比例(如 `"16:9"`、`"1:1"`、`"4:5"`)就照用,原样透传给下游。画幅只影响 `visual` 的构图措辞,不改变结构与镜数。
|
||||
- **时长档位由输入给定**:`total_duration` 只能取 `15 | 30 | 60 | 90` 之一(输入未指定时默认 `60`)。**不要写死。**
|
||||
- **每 15 秒切一镜**:`segment_count = total_duration / 15`,即 15→1 镜、30→2 镜、60→4 镜、90→6 镜;每镜 `duration=15`;`index` 从 0 连续递增(粗暴切,不做复杂时长算法)。
|
||||
- 各档位的 4 镜功能(role)如何分配/压缩/扩展,见 `references/methodology.md`「档位 × 黄金结构映射」。
|
||||
- `entities[].id` 全局唯一,`segments[].entity_refs` 与 `speaker` 只能引用已声明的 id。
|
||||
- **每个声明的 entity 至少被一个 segment 引用**(不留孤儿 entity)。
|
||||
- `visual_prompt` 由你自动生成,小白无需打字。
|
||||
- 不要输出 schema 之外的字段,也不要省略必填字段。
|
||||
|
||||
### 铁律 2 · 输出前自检
|
||||
|
||||
输出 JSON 之前,**先在内部逐条跑一遍** `references/checklist.md` 的自检清单。
|
||||
发现问题先改再输出,不要带着已知问题输出。自检是内部过程,不展示给用户。
|
||||
|
||||
### 铁律 3 · 写作红线(旁白 / 文案)
|
||||
|
||||
详见 `references/methodology.md`「旁白红线」。最关键的几条:
|
||||
- **口语化**,像真人对着镜头说话,不准书面腔 / AI 腔。
|
||||
- **每镜 narration ≤ 55 字**(Seedance 15 秒内直接发声,字数 = 可懂语速上限)。
|
||||
- **禁违规词**:医疗功效(治疗/根治/抗癌…)、绝对化用语(最/第一/100%/国家级…)一律不写。
|
||||
- 不浮夸、不空喊,卖点要落到「商品怎么解决痛点」。
|
||||
|
||||
### 铁律 4 · 一键自动化(与影视母版相反)
|
||||
|
||||
母版分步暂停等用户确认;本技能服务电商小白,要求**一次性生成完整 JSON**,
|
||||
中途**不向用户提问、不暂停、不展示思考过程**。所有提示词替用户包好。
|
||||
(仅当输入信息严重缺失到无法生成时,才回退提问——见路由表「信息不足」。)
|
||||
|
||||
---
|
||||
|
||||
## 默认参数(不暴露给用户,直接套用)
|
||||
|
||||
| 参数 | 值 |
|
||||
| ---- | ---- |
|
||||
| 画幅 | **默认 9:16 竖屏**,由输入可覆盖(16:9 / 1:1 / 4:5 等照用) |
|
||||
| 总时长 | **15 / 30 / 60 / 90 秒四档**,由输入给定;未指定时默认 60 |
|
||||
| 分镜 | 每 15 秒一镜,均分(粗暴切,不做复杂算法)→ 1 / 2 / 4 / 6 镜 |
|
||||
| 镜头功能 | 钩子 → 痛点 → 卖点 → CTA(黄金结构;不同档位按映射表压缩/扩展,见方法论) |
|
||||
| 发声方式 | Seedance 直接生成画面+音效+人声(**不走 TTS**) |
|
||||
|
||||
---
|
||||
|
||||
## 输入模式路由(3 种输入 → 同一份 JSON)
|
||||
|
||||
先判断输入属于哪种模式,加载对应参考资料,最后都收敛到铁律 1 的同一份结构化输出。
|
||||
|
||||
| 模式 | 触发条件 | 处理逻辑 | 需读取 |
|
||||
| ---- | ---- | ---- | ---- |
|
||||
| **① 全自动** | 用户只给【商品信息 + 前置条件(调性/平台/时长/卖点勾选)】,无主题无原稿 | 凭商品与前置条件,自动定档、选 tone、造 entity、按映射填镜 | `methodology.md` + `hook-library.md` + `category-playbook.md` + `platform-tone.md` |
|
||||
| **② 一句话** | 用户额外给了一句主题(如「主打熬夜党救星」) | 以该主题为脚本主轴扩写,其余同全自动 | 同上(主题优先于自动选题) |
|
||||
| **③ 改稿** | 用户给了已有脚本/文案 | **保留用户原意**,只增强钩子/节奏/卖点/CTA,并归一化到 JSON 结构 | `methodology.md` + `hook-library.md` + `checklist.md` |
|
||||
| 信息不足 | 连商品信息都缺,无法生成 | 唯一允许的回退:用一句话问清最少必要信息 | — |
|
||||
|
||||
**进入任何模式前,必须先读取该行列出的参考资料。**
|
||||
|
||||
---
|
||||
|
||||
## 生成流程(内部执行,一次走完,不暂停)
|
||||
|
||||
1. **路由** — 判定输入模式(①/②/③),加载对应 references。
|
||||
2. **定档** — 从输入读取画幅(`aspect_ratio` 默认 9:16)与时长档位(15/30/60/90,未指定默认 60),算出 `segment_count = total_duration / 15`。
|
||||
3. **定调(tone)** — 依据品类话术 + 平台调性 + 前置条件,选定 `tone`;②③ 模式尊重用户已表达的倾向。
|
||||
4. **抽取/创建 entities** — 识别脚本需要的角色 / 场景 / 商品;为每个 entity 写一份**全脚本共用**的 `visual_prompt`(保证多镜同一角色同一张脸);可选写 `voice_ref` 锁音色。
|
||||
5. **按档位填黄金结构** — 依「档位 × 黄金结构映射表」给每个 segment 分配 `role`;钩子镜套用 `hook-library.md` 的公式。
|
||||
6. **写 narration / visual / 商品露出** — 每镜旁白 ≤55 字、口语化、过红线;每镜规划自然的 `product_exposure`。
|
||||
7. **连引用** — 填 `entity_refs` 与 `speaker`,确认每个 entity 都被引用、id 都合法。
|
||||
8. **自检** — 跑 `checklist.md`,过了再输出。
|
||||
9. **输出** — 仅输出铁律 1 的 JSON。
|
||||
|
||||
---
|
||||
|
||||
## 参考资料索引
|
||||
|
||||
| 文件 | 内容 | 何时读取 |
|
||||
| ---- | ---- | ---- |
|
||||
| `references/methodology.md` | 黄金结构模板(钩子→痛点→卖点→CTA)、档位 × 结构映射表、entity 一致性原则、商品露出规范、旁白红线(含违规词清单) | **每次生成都读** |
|
||||
| `references/hook-library.md` | 前 3 秒钩子公式库(痛点提问 / 反差 / 数字冲击 / 身份代入 …,含例句) | 每次生成都读(写钩子镜时) |
|
||||
| `references/category-playbook.md` | 分品类话术(美妆 / 食品 / 3C / 服饰 / 家居…的语气与卖点侧重) | 全自动 / 一句话模式 |
|
||||
| `references/platform-tone.md` | 平台调性(抖音 / 快手 / 小红书 / 视频号 的节奏与风格差异) | 全自动 / 一句话模式 |
|
||||
| `references/checklist.md` | 电商版自检清单 + 输出契约校验(钩子够强、旁白≤55字、镜数=时长/15、role 齐、entity 全被引用、违规词扫描) | **输出前必读** |
|
||||
@@ -0,0 +1,55 @@
|
||||
# 分品类话术
|
||||
|
||||
> 全自动 / 一句话模式读。根据商品所属品类,调整语气与卖点侧重。
|
||||
> 不确定品类时,按商品功能就近归类;都不沾就用"通用"原则(讲场景痛点 + 真实使用感)。
|
||||
|
||||
每个品类给:**语气** / **卖点侧重** / **常用露出** / **违规雷区** / **示例旁白**。
|
||||
|
||||
---
|
||||
|
||||
## 美妆护肤
|
||||
|
||||
- **语气**:闺蜜种草、真实分享,带点"亲测"口吻,忌假大空。
|
||||
- **卖点侧重**:上脸质地、即时感受(清爽/水润/不卡粉)、成分通俗化、前后状态对比。
|
||||
- **常用露出**:质地特写、上脸使用中、成分/包装特写。
|
||||
- **违规雷区**:美白/祛斑/抗皱写成医疗功效("淡化""看起来更均匀"可以,"祛斑根治"禁);忌"最/第一/100% 有效"。
|
||||
- **示例**:「上脸是那种水水的,吸收完一点不黏,油皮也能闭眼入。」
|
||||
|
||||
## 食品饮料
|
||||
|
||||
- **语气**:馋、香、解馋,调动味觉想象,节奏轻快。
|
||||
- **卖点侧重**:口感(爆汁/酥脆/拉丝)、配料干净、场景(追剧/早餐/解馋)、分量与价格。
|
||||
- **常用露出**:特写(拉丝/爆浆瞬间)、吃播使用中、包装正面。
|
||||
- **违规雷区**:保健/疗效("降三高""治便秘"禁);"零添加""无糖"等需有据,别乱标。
|
||||
- **示例**:「一口下去芝士直接拉丝,半夜看到真的会饿,我已经囤了三箱。」
|
||||
|
||||
## 3C 数码
|
||||
|
||||
- **语气**:理性测评、参数说人话,给"懂行"的信任感。
|
||||
- **卖点侧重**:核心性能、对比同价位、真实使用场景痛点解决、续航/手感/兼容。
|
||||
- **常用露出**:手持、功能演示使用中、接口/细节特写、对比展示。
|
||||
- **违规雷区**:跑分写成"最强/第一";夸大续航/防水等参数;忌绝对化。
|
||||
- **示例**:「同价位里它的续航是真顶用,我出门一天没带充电宝也没焦虑。」
|
||||
|
||||
## 服饰鞋包
|
||||
|
||||
- **语气**:穿搭分享、身材/场景导向,强调上身效果。
|
||||
- **卖点侧重**:版型显瘦/显高、面料舒适、好搭配、场合适配、尺码建议。
|
||||
- **常用露出**:上身使用中、面料细节特写、多场景/多角度展示。
|
||||
- **违规雷区**:材质成分虚标;"显瘦 20 斤"等夸张数字;绝对化用语。
|
||||
- **示例**:「这个版型是真的藏肉,梨形身材穿上腿一下显直,通勤约会都能穿。」
|
||||
|
||||
## 家居日用
|
||||
|
||||
- **语气**:实用、解决麻烦、相见恨晚的"生活妙招"感。
|
||||
- **卖点侧重**:解决具体家务痛点、省时省力、材质安全、收纳/清洁效率。
|
||||
- **常用露出**:使用中(前后对比)、场景摆放、细节特写。
|
||||
- **违规雷区**:抗菌/除螨率等需有据,别写成医疗;忌"最/唯一"。
|
||||
- **示例**:「以前擦灶台要使劲搓,喷一下擦一擦油污自己化开,懒人狂喜。」
|
||||
|
||||
---
|
||||
|
||||
## 通用原则(品类不明时)
|
||||
|
||||
讲清"**谁、在什么场景、遇到什么麻烦、这个商品怎么帮上忙、现在怎么买**",
|
||||
语气贴近真实用户分享,卖点永远挂在前面铺的痛点上。
|
||||
@@ -0,0 +1,51 @@
|
||||
# 输出前自检清单
|
||||
|
||||
> 输出 JSON 之前必读。**逐条内部过一遍,命中问题先改再输出。** 自检过程不展示给用户。
|
||||
> 任一"硬校验"不通过 = 不合格,必须修。
|
||||
|
||||
---
|
||||
|
||||
## A. 输出契约校验(硬,必须全过)
|
||||
|
||||
- [ ] 最终只输出**一个** JSON 对象,无 schema 外的多余文字/注释。
|
||||
- [ ] `tone` ∈ `{种草, 测评, 剧情, 痛点}`。
|
||||
- [ ] `aspect_ratio` 存在;输入指定了就用输入值,未指定为 `"9:16"`。
|
||||
- [ ] `total_duration` ∈ `{15, 30, 60, 90}`。
|
||||
- [ ] `segment_count == total_duration / 15`,且 `segments` 数组长度等于它。
|
||||
- [ ] 每个 `segment.duration == 15`;`index` 从 0 连续递增无跳号。
|
||||
- [ ] 每个 `segment.role` ∈ `{钩子, 痛点, 卖点, CTA}`。
|
||||
- [ ] 首镜 `role == 钩子`;末镜收 CTA(独立 CTA 镜,或末镜旁白末尾含明确行动指令)。
|
||||
- [ ] `entities` 每项 `type` ∈ `{character, scene, product}`,`id` 全局唯一。
|
||||
- [ ] `entity_refs` 与 `speaker` 引用的 id **都已在 entities 中声明**(无悬空引用)。
|
||||
- [ ] **每个声明的 entity 至少被一个 segment 引用**(无孤儿 entity)。
|
||||
- [ ] `speaker` 要么为 `null`,要么指向一个 `type==character` 的 id。
|
||||
- [ ] 必填字段无缺失:顶层 `hook/tone/aspect_ratio/total_duration/segment_count/entities/segments`;
|
||||
每 entity 有 `id/type/name/visual_prompt/ref_index`;
|
||||
每 segment 有 `index/duration/role/narration/visual/product_exposure/entity_refs`。
|
||||
|
||||
## B. 内容质检(钩子 / 结构 / 一致性)
|
||||
|
||||
- [ ] 钩子镜第一句在前 3 秒抛出钩子,套用了 `hook-library.md` 的某个公式。
|
||||
- [ ] 顶层 `hook` 与钩子镜口径一致。
|
||||
- [ ] 卖点镜的卖点**挂在前面铺的痛点上**,不是干罗列参数。
|
||||
- [ ] CTA 给了明确动作(点小黄车/领券/主页链接)。
|
||||
- [ ] 档位映射正确:role 序列符合 `methodology.md`「档位 × 黄金结构映射」表。
|
||||
- [ ] 90s 的多个卖点镜**各有侧重不重复**。
|
||||
- [ ] 同一角色/场景/商品全程复用同一 entity(没有给同一对象写出两份 visual_prompt)。
|
||||
- [ ] 每个 `visual_prompt` 信息足够喂图模型(角色/场景/商品的外观特征写清)。
|
||||
- [ ] 每镜 `product_exposure` 自然、与 role 匹配(参考方法论露出表)。
|
||||
|
||||
## C. 旁白红线扫描(逐镜)
|
||||
|
||||
- [ ] 每镜 `narration` ≤ 55 字(硬上限;目标 ≤50 留缓冲,逐字数一遍,别凭感觉)。
|
||||
- [ ] 口语化,无书面腔/AI 腔("综上""不仅…而且""值得一提"等已清除)。
|
||||
- [ ] **违规词扫描**:无医疗功效词(治疗/根治/抗癌/消炎/排毒/速效…)。
|
||||
- [ ] **绝对化用语扫描**:无 最/第一/唯一/100%/国家级/永久/绝对/史上 等。
|
||||
- [ ] 无虚假承诺(三天见效/永不反弹/包治…)。
|
||||
|
||||
---
|
||||
|
||||
## 通过标准
|
||||
|
||||
A 区**全部**通过 + B/C 区无红线命中 → 可输出。
|
||||
任何一条不过:先改,再重跑本清单,直到全过,才输出最终 JSON。
|
||||
@@ -0,0 +1,66 @@
|
||||
# 前 3 秒钩子公式库
|
||||
|
||||
> 写"钩子"镜(`role: 钩子`)时读。钩子镜的 `narration` 开头第一句即套用以下任一公式。
|
||||
> 同时把这句钩子提炼进顶层 `hook` 字段。
|
||||
|
||||
钩子的唯一任务:**让用户在 3 秒内停止划走**。手段是制造"和我有关 / 没想到 / 想看下去"。
|
||||
|
||||
---
|
||||
|
||||
## 公式 1 · 痛点提问(最稳,默认首选)
|
||||
|
||||
直接问出用户正在经历的痛点,让人下意识"对,我就是"。
|
||||
|
||||
- 公式:`你是不是也 + [具体痛点场景]?`
|
||||
- 例:「你是不是也一熬夜,第二天脸就垮、暗沉到没法看?」
|
||||
- 例:「天天敷面膜还是干,是不是钱白花了?」
|
||||
|
||||
## 公式 2 · 反差冲击
|
||||
|
||||
先给一个反直觉的结论或前后对比,制造认知落差。
|
||||
|
||||
- 公式:`别再 [常见做法] 了,其实 [反差真相]` / `[A] 和 [B] 的差距,全在这一步`
|
||||
- 例:「别再狂涂面霜了,越涂越干的真相在这。」
|
||||
- 例:「同样熬夜,她第二天像没事人,差别就这一瓶。」
|
||||
|
||||
## 公式 3 · 数字冲击
|
||||
|
||||
用具体数字制造可信的强刺激(数字要真实、不踩绝对化红线)。
|
||||
|
||||
- 公式:`[数字] + [结果/对比]`
|
||||
- 例:「3 秒上脸,毛孔像被一键磨皮。」
|
||||
- 例:「一瓶顶我以前三瓶,算下来一天不到一块钱。」
|
||||
|
||||
## 公式 4 · 身份代入
|
||||
|
||||
直接点名目标人群,让"自己人"瞬间锁定。
|
||||
|
||||
- 公式:`[身份/人群] 必看 / 给 [人群] 的 [品类]`
|
||||
- 例:「熬夜党救星来了,做夜班的姐妹蹲一下。」
|
||||
- 例:「油皮看过来,夏天再也不用一天补三次妆。」
|
||||
|
||||
## 公式 5 · 悬念留白
|
||||
|
||||
抛出结果但藏起原因,逼用户看下去。
|
||||
|
||||
- 公式:`[惊人结果],关键是 [先不说]` / `我做了一件事,结果……`
|
||||
- 例:「闺蜜以为我去医美了,其实我只换了它。」
|
||||
- 例:「就因为睡前多做这一步,第二天状态稳了。」
|
||||
|
||||
## 公式 6 · 场景代入
|
||||
|
||||
把镜头放进一个高共鸣的具体生活瞬间,让人"我也这样"。
|
||||
|
||||
- 公式:`[具体时间/场景] + [尴尬或困扰瞬间]`
|
||||
- 例:「早高峰挤地铁,妆花一半还冒油,太崩溃了。」
|
||||
- 例:「加班到凌晨,照镜子那一刻自己都吓一跳。」
|
||||
|
||||
---
|
||||
|
||||
## 钩子自检(写完钩子镜后内部过一遍)
|
||||
|
||||
- [ ] 第一句是否在前 3 秒(约 15 字内)就抛出钩子?
|
||||
- [ ] 是否和目标人群"有关",能让人对号入座?
|
||||
- [ ] 是否制造了"想继续看"的理由(痛点/反差/悬念)?
|
||||
- [ ] 是否避开了违规词(最/第一/100%/治疗…)?
|
||||
- [ ] 顶层 `hook` 字段是否与钩子镜口径一致?
|
||||
@@ -0,0 +1,85 @@
|
||||
# 电商带货脚本 · 核心方法论
|
||||
|
||||
> 每次生成都读。本文件是脚本的"骨架 + 红线",钩子句式见 `hook-library.md`。
|
||||
|
||||
---
|
||||
|
||||
## 一、黄金结构:钩子 → 痛点 → 卖点 → CTA
|
||||
|
||||
带货短视频的灵魂是**前 3 秒留人 + 结尾促转化**。四个功能镜各司其职:
|
||||
|
||||
| role | 功能 | 它要做的事 | 旁白要点 |
|
||||
| ---- | ---- | ---- | ---- |
|
||||
| **钩子** | 3 秒留人 | 第一句话/第一帧就让人停下不划走 | 用 `hook-library.md` 的公式,制造痛点共鸣或反差冲击 |
|
||||
| **痛点** | 共鸣 | 把用户"对,我就这样"的处境讲出来 | 具体场景化,不抽象,让人对号入座 |
|
||||
| **卖点** | 商品解决 | 商品如何**恰好**解决上面的痛点 | 卖点落到痛点上,给"凭什么信"的理由(成分/效果/对比/口碑) |
|
||||
| **CTA** | 转化行动 | 临门一脚,告诉用户现在做什么 | 明确动作 + 紧迫感(点下方小黄车 / 主页领券 / 限时) |
|
||||
|
||||
**铁原则:**
|
||||
- **首镜永远是钩子**——3 秒内必须抛出钩子,否则用户划走。
|
||||
- **末镜永远收 CTA**——可以是独立 CTA 镜,也可以是末镜旁白末尾收一句行动指令。
|
||||
- **卖点必须挂在痛点上**——不是罗列参数,是"这个痛点 → 商品这样解决"。
|
||||
|
||||
---
|
||||
|
||||
## 二、档位 × 黄金结构映射(关键)
|
||||
|
||||
时长由输入给定(15/30/60/90 四档),每 15 秒一镜,`segment_count = total_duration / 15`。
|
||||
镜数变了,四个功能要**压缩或扩展**,按下表分配 `role`:
|
||||
|
||||
| 档位 | 镜数 | role 序列(index 0→N) | 压缩/扩展说明 |
|
||||
| ---- | ---- | ---- | ---- |
|
||||
| **15s** | 1 | `[钩子]` | 一镜到底:开头 3 秒钩子 → 中段一句卖点 → 末尾一句 CTA,全压进这 15 秒旁白里 |
|
||||
| **30s** | 2 | `[钩子, 卖点]` | 镜0 钩子里带出痛点;镜1 讲卖点并在旁白末尾收一句 CTA |
|
||||
| **60s** | 4 | `[钩子, 痛点, 卖点, CTA]` | 标准黄金结构,一镜一功能 |
|
||||
| **90s** | 6 | `[钩子, 痛点, 卖点, 卖点, 卖点, CTA]` | 痛点后给 3 个卖点镜:核心卖点 / 场景化演示 / 信任背书(口碑·数据);末镜 CTA |
|
||||
|
||||
说明:
|
||||
- `role` 字段只能取 `钩子|痛点|卖点|CTA` 四个枚举值。"信任背书""场景演示"等都归入 `卖点`。
|
||||
- 短档位(15/30s)靠**旁白内压缩多功能**达成"留人+转化",不要因为镜少就丢掉 CTA。
|
||||
- 90s 的 3 个卖点镜要**各有侧重、不重复**(核心功效 / 真实使用场景 / 别人为什么买)。
|
||||
|
||||
---
|
||||
|
||||
## 三、entity 一致性原则
|
||||
|
||||
`entities` 是全脚本共享的角色 / 场景 / 商品池,保证**多镜同一角色是同一张脸、同一商品是同一个包装**。
|
||||
|
||||
- **一个角色/场景/商品 = 一个 entity = 一份 `visual_prompt`**。多镜复用同一 id,**绝不为同一对象写两份 visual_prompt**(会导致下游生图人脸/包装漂移)。
|
||||
- `visual_prompt` 由你**自动生成**(小白不打字):写清外观特征(角色:性别/年龄段/发型/穿着/气质;场景:地点/光线/风格;商品:品类/包装/颜色/摆放),用于直接喂图模型。
|
||||
- `type` 三选一:`character`(人) / `scene`(环境) / `product`(商品)。一份脚本通常至少 1 个 `product`。
|
||||
- `ref_index` 是该 entity 在图集里的参考序号(从 1 递增,供下游三视图/参考图对齐)。
|
||||
- `voice_ref`(二期,可选):角色音色参考,用于锁音色;无则 `null`。
|
||||
- **每个声明的 entity 至少被一个 segment 的 `entity_refs` 引用**,不留孤儿。
|
||||
- `segments[].speaker` 指向某 `character` 的 id 表示该镜由角色开口说;`null` = 画外旁白。
|
||||
|
||||
---
|
||||
|
||||
## 四、商品露出规范(`product_exposure`)
|
||||
|
||||
每镜都要规划商品怎么自然出现,避免生硬插入。按 role 给推荐露出方式:
|
||||
|
||||
| role | 推荐露出 | 说明 |
|
||||
| ---- | ---- | ---- |
|
||||
| 钩子 | 可弱露出或不露 | 钩子先抓人,商品可作悬念,第 1 镜末尾闪一下也行 |
|
||||
| 痛点 | 不露 / 反面对照 | 展示"没有它"的糟糕状态,商品先按住 |
|
||||
| 卖点 | **特写 / 使用中** | 商品成为主角:成分特写、质地特写、上脸/上手使用中 |
|
||||
| CTA | 手持 / 包装正面 | 手持商品对镜头,包装正面清晰,配合行动指令 |
|
||||
|
||||
露出方式词汇统一用:`手持` / `特写` / `使用中` / `包装正面` / `场景摆放` / `对比展示`(可组合,如"手持+特写")。
|
||||
|
||||
---
|
||||
|
||||
## 五、旁白红线(硬规则,违反即不合格)
|
||||
|
||||
- **口语化**:像真人对着镜头唠嗑,不准书面腔 / 不准 AI 腔("综上所述""不仅…而且""值得一提的是"全禁)。
|
||||
- **每镜 `narration` ≤ 55 字**:Seedance 在 15 秒内直接发声,55 字是可懂语速**硬上限**。
|
||||
**写作目标 ≤ 50 字**,留 5 字缓冲——宁可短、不要卡满;短档位(15/30s)一镜要装多功能时尤其要狠删,先保 CTA 不被砍。
|
||||
- **不浮夸、不空喊**:卖点要给具体理由,不堆形容词。
|
||||
- **违规词禁令**(电商广告法红线,一律不写):
|
||||
- **医疗功效类**:治疗 / 根治 / 疗效 / 抗癌 / 消炎 / 杀菌(无证) / 排毒 / 速效 / 抑制 ××病 …
|
||||
- **绝对化用语**:最 / 第一 / 顶级 / 唯一 / 100% / 国家级 / 世界级 / 永久 / 绝对 / 史上 …
|
||||
- **虚假承诺**:包治 / 三天见效 / 立刻变白 / 永不反弹 …
|
||||
- 替换策略:用"帮助""更""不少人反馈""上脸清爽"等柔性、主观化表达替代。
|
||||
|
||||
> 自检阶段必须逐镜扫一遍违规词,命中即改写。详见 `checklist.md`。
|
||||
@@ -0,0 +1,47 @@
|
||||
# 平台调性
|
||||
|
||||
> 全自动 / 一句话模式读。根据投放平台(前置条件给定)调整节奏与风格。
|
||||
> 未指定平台时,默认按"抖音"通用带货节奏。
|
||||
|
||||
每个平台给:**节奏** / **风格** / **钩子偏好** / **tone 倾向** / **一句话定调**。
|
||||
|
||||
---
|
||||
|
||||
## 抖音
|
||||
|
||||
- **节奏**:快、强钩子、信息密集,前 3 秒定生死,反转/爽点前置。
|
||||
- **风格**:高能、口播感强、配合小黄车,直给卖点。
|
||||
- **钩子偏好**:痛点提问、数字冲击、反差。
|
||||
- **tone 倾向**:`种草` / `痛点`。
|
||||
- **定调**:开门见山抓人,节奏别拖,CTA 干脆("点下方小黄车")。
|
||||
|
||||
## 快手
|
||||
|
||||
- **节奏**:偏生活化、接地气,信任感和性价比优先。
|
||||
- **风格**:实在、像熟人推荐、强调"便宜大碗/老铁信得过"。
|
||||
- **钩子偏好**:身份代入、场景代入、价格冲击。
|
||||
- **tone 倾向**:`种草` / `测评`。
|
||||
- **定调**:朴实可信,突出实惠和真实使用,少花活。
|
||||
|
||||
## 小红书
|
||||
|
||||
- **节奏**:偏慢、重质感、像写笔记/真实测评。
|
||||
- **风格**:精致、审美在线、第一人称"我亲测",软种草不硬推。
|
||||
- **钩子偏好**:悬念留白、反差、身份代入(细分人群)。
|
||||
- **tone 倾向**:`种草` / `测评`。
|
||||
- **定调**:真诚分享感,画面要美,卖点融进使用体验,CTA 柔("主页有链接/评论区扣")。
|
||||
|
||||
## 视频号
|
||||
|
||||
- **节奏**:适中,受众偏成熟、家庭场景多。
|
||||
- **风格**:温和、可信、强调实用与口碑,社交裂变属性强。
|
||||
- **钩子偏好**:痛点提问、场景代入。
|
||||
- **tone 倾向**:`种草` / `剧情`。
|
||||
- **定调**:稳重可信,讲清实用价值,适合家庭/品质向商品。
|
||||
|
||||
---
|
||||
|
||||
## 用法提示
|
||||
|
||||
- 平台调性影响 **节奏(旁白语气松紧)** 与 **tone 选择**,但不改变黄金结构与输出契约。
|
||||
- 平台 × 品类叠加时,以"商品适配"为先:例如 3C 上小红书仍偏测评质感,上抖音则更直给。
|
||||
+7004
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,173 @@
|
||||
# AirShelf · AI 生成 Agent 化 · 交接文档
|
||||
|
||||
> **日期:** 2026-06-17(凌晨)
|
||||
> **交接人:** Claude(尹希乐 / seaislee 指导)→ **接手人:** 张业昌 + 你的 Claude + 前端 UX
|
||||
> **分支:** `dev`(本批改动已推送)
|
||||
> **一句话:** 把脚本生成升级成「多模型可选 + 出稿/改稿一体的**流式对话 agent**(挂电商 skill)」,并把 商品→脚本→图片→故事板→视频 的 SOP 用**可插拔 Provider** 打通;参考图分镜(gpt-image-2 @图N)、模特库、Seedance 出音 全部就位。
|
||||
|
||||
---
|
||||
|
||||
## 0. TL;DR · 拿到就能跑
|
||||
|
||||
```bash
|
||||
# 后端(端口 8010)
|
||||
cd core/backend
|
||||
.venv/Scripts/python.exe manage.py migrate # 跑新迁移(0005/0006 + projects 0002)
|
||||
.venv/Scripts/python.exe manage.py runserver 0.0.0.0:8010
|
||||
# 另起 celery worker(图片/故事板/视频是异步,靠 worker 执行;没 worker 这几趴会卡)
|
||||
.venv/Scripts/python.exe -m celery -A airshelf worker -l info -P solo
|
||||
|
||||
# 前端(端口 5173,vite 代理 /api → 8010)
|
||||
cd core/frontend
|
||||
npm install && npm run dev
|
||||
```
|
||||
|
||||
`.env` 里**凭证已就绪**(tokenssr / 飞书 / 火山审核借用 AK/SK / 豆包 TTS),开箱即用。**⚠️ 见 §7 有一处借用密钥要换。**
|
||||
|
||||
---
|
||||
|
||||
## 1. 成熟度总表(诚实标注)
|
||||
|
||||
| 模块 | 状态 | 验证方式 |
|
||||
| ---- | ---- | ---- |
|
||||
| 可插拔 Provider 层(tokenssr/任意中转站) | ✅ 已完成·已验证 | 真打 tokenssr 流式 chat 通过 |
|
||||
| 结构化脚本 agent + 流式 SSE(出稿/改稿/3模式/多模型) | ✅ 已完成·**双验证** | 后端 HTTP 探针 + **无头浏览器视觉自检**(截图见 `_qa_shots/`) |
|
||||
| gpt-image-2 参考图出图 + 落 TOS | ✅ 已完成·已验证 | 文生图→TOS→参考图合成 整链跑通 |
|
||||
| 故事板 @图1@图2@图3 多锚点合成 | ✅ 代码完成 | 逻辑接好,建议接 worker 后端到端复跑一次 |
|
||||
| Seedance 视频出音(generate_audio) | ✅ 已打开 | 默认 True;原来写死 False |
|
||||
| 多模型 seed(豆包/GPT-5.5/Gemini + gpt-image-2 + Seedance) | ✅ 已完成 | 数据迁移 0006,`get_default_model` 选取正确 |
|
||||
| 模特库生成器(9:16氛围图→16:9白底三视图) | ✅ 已完成·已验证 | 跑通 1 个,三视图角色一致性极好(截图 `_qa_shots/model_threeview.png`) |
|
||||
| 前端脚本趴(流式工具卡+思考流+模型下拉+改稿) | ✅ 已完成·**浏览器视觉验证** | 见 `_qa_shots/01..03` |
|
||||
| **火山人像素材库审核(绿/红标)** | 🟡 **设计完成·未接线** | 见 §6,AirDrama 有现成代码可照搬,AK/SK 已备好 |
|
||||
| 计费(按 token 精确扣) | 🟡 沿用现有(每次调用扣 unit_price) | 真 token 计量是后续优化项 |
|
||||
|
||||
---
|
||||
|
||||
## 2. 架构原则(务必延续)
|
||||
|
||||
**Provider 可插拔 —— 除火山官方直连外,一切走「OpenAI 兼容中转站」,换站零改代码。**
|
||||
|
||||
- 火山(豆包文本 / SeeDream 生图 / Seedance 视频)= 官方直连,`VolcanoArkProvider`。
|
||||
- 其余(tokenssr / yunqi / 任意 New-API 网关)= 通用 `OpenAICompatibleProvider(base_url, api_key)`。
|
||||
- 分流在 `services.build_provider()`:`provider.name in OFFICIAL_DIRECT_PROVIDERS` 走火山,否则走通用适配器。
|
||||
- **加/换中转站 = DB 加一行 `ModelProvider`(base_url + 可选 api_key)**,代码一行不改。
|
||||
- 密钥解析顺序:`ModelProvider.api_key`(DB)→ `settings.PROVIDER_KEYS`(.env)。**密钥默认只在 .env,不进库**(seed 的 tokenssr provider 的 api_key 留空,运行时从 .env 取)。
|
||||
|
||||
**关键文件:**
|
||||
- `core/backend/apps/ai/providers/openai_compatible.py` — 通用适配器(chat / 流式 chat / 生图 / **参考图 image_edit**)
|
||||
- `core/backend/apps/ai/providers/volcano.py` — 基类(新增 `chat_completion_stream` 流式 + `generate_audio` 开关)
|
||||
- `core/backend/apps/ai/services.py` — `build_provider / resolve_provider_credentials / get_{text,image,video}_provider`
|
||||
|
||||
---
|
||||
|
||||
## 3. 脚本 Agent(核心)
|
||||
|
||||
### 流式 SSE 端点
|
||||
`POST /api/projects/{id}/script-agent-stream/` → `text/event-stream`,逐帧 `data: {json}`。
|
||||
|
||||
事件类型:
|
||||
- `tool` `{id,label?,status:running|done|error}` — 工具卡(加载skill / 分析商品 / 生成分镜 / 提取实体 / 自检)
|
||||
- `delta` `{text}` — 模型思考前言逐字(JSON 部分后端隐藏不外露)
|
||||
- `draft` `{draft}` — 规范化后的 **ScriptDraft**(结构化)
|
||||
- `saved` `{script_version_id, version}` — 已落库的 ScriptVersion(含 segments + metadata)
|
||||
- `done` / `error`
|
||||
|
||||
> **DRF 坑(已解决):** SSE 必须给 action 挂 `ServerSentEventRenderer`(media_type=text/event-stream),否则 `Accept: text/event-stream` 直接 406。错误响应用纯 Django `JsonResponse` 绕开 DRF 渲染。见 `projects/views.py`。
|
||||
|
||||
### 3 种输入模式(前端已接)
|
||||
- **auto(全自动)**:仅商品信息 → 自动定档/选 tone/造 entity/填黄金结构。
|
||||
- **theme(一句话主题)**:用户给主题 → 以主题为主轴扩写。
|
||||
- **revise(改稿)**:已有脚本 → 基于当前脚本增强(前端追问自动走此模式,带 `base_version_id`)。
|
||||
|
||||
### 多模型
|
||||
请求带 `model_config_id`(前端脚本助手右上**模型下拉**:豆包 / GPT-5.5 / Gemini 3 Pro);缺省用默认文本模型。已实测豆包(官方直连)+ GPT-5.5(经 tokenssr)两条路都通。
|
||||
|
||||
### 结构化契约 ScriptDraft
|
||||
脚本不再是「散文 + 正则」,而是结构化 JSON(`script_agent.normalize_draft` 后端兜底校验):三层 = 脚本头(hook/tone/aspect/duration)+ entities(角色/场景/商品,带 visual_prompt/ref_index,**全脚本共享保一致**)+ segments(role 钩子/痛点/卖点/CTA、narration≤55字、speaker、visual、product_exposure、**entity_refs**)。
|
||||
|
||||
- 落库:`ScriptVersion`(content=JSON,metadata=hook/tone/entities)+ `ScriptSegment`(新增字段 role/speaker/product_exposure/entity_refs)。
|
||||
- **entities 回填 `project.metadata`**(cast/scenes/cast_prompts/scene_prompts + script_entities)→ 复用下游已有的基础资产 seed + 故事板 @图N。
|
||||
|
||||
### 电商 skill
|
||||
`skills/ecommerce-video-script/`(SKILL.md + 5 references)。`script_agent.load_ecommerce_skill()` 运行时加载为系统提示词,**模型无关**。运行时输出协议放开了「先 1 句口语前言 + 再 JSON」以驱动流式体感。
|
||||
|
||||
**关键文件:** `core/backend/apps/ai/script_agent.py`、`apps/projects/views.py`(`script_agent_stream` action)、前端 `api.ts`(`agentScriptStream`)、`routes/pipeline.tsx`(`runScriptGeneration` 改 SSE 驱动)。
|
||||
|
||||
---
|
||||
|
||||
## 4. 图片 / 故事板 / 视频
|
||||
|
||||
- **图片(基础资产 / 模特 / 套图)**:默认图像模型 = `tokenssr:gpt-image-2`(支持参考图)。原 `yunqi:gpt-image-2`(只能纯文生图)已在迁移里停用。
|
||||
- **故事板 @图N**:`services._storyboard_reference_images()` 按本镜 `entity_refs` 取角色/场景/商品的已采用基础资产,用 `provider.image_edit(images=[url1,url2,url3])` 多图合成本镜(锁脸锁商品)。provider 不支持 image_edit 时自动回退纯文生图。
|
||||
- **视频(Seedance)**:`create_video_task(generate_audio=True)` 已打开,直接出画面+音效+人声(**不走 TTS**)。慢:单段约 5–10 分钟(凌晨可能 3–4 分钟),轮询端点 `poll-video-segment` 已是「秒回不阻塞」式。
|
||||
|
||||
---
|
||||
|
||||
## 5. 模特库
|
||||
|
||||
`python manage.py seed_demo_models --count 2 [--team <id>] [--brief "画像"]`
|
||||
|
||||
逻辑(`apps/ai/model_library.py`):先 gpt-image-2 出 9:16 氛围正面图,再以它为参考出 16:9 白底三视图(提示词「参考图1角色,生成角色三视图,从左往右依次为:胸像特写,全身正面,全身侧面,全身背面,白色背景」),两张都存为 person 类 Asset(`metadata.kind="model"`)。**已跑通,三视图角色一致性很好**(见 `_qa_shots/model_threeview.png`)。
|
||||
|
||||
---
|
||||
|
||||
## 6. 🟡 火山人像素材库审核(绿/红标)—— 待接线
|
||||
|
||||
**目标:** 真人素材进火山素材库审核,前端只显示绿盾(active)/红标(failed,提示改提示词重生)。一团队一素材组(单组 500 万)。
|
||||
|
||||
**现成代码在 AirDrama,直接照搬:**
|
||||
- `C:\Airlabs_Project\Airflow Studio\AirDrama\video-shuoshan\backend\utils\assets_client.py` — `create_asset_group / create_asset / get_asset`(volcengine SDK,AK/SK 签名,异步审核状态轮询 active/failed/processing)。
|
||||
- 对应 `AssetGroup(team, remote_group_id)` + `Asset(group, remote_asset_id, status, url, error_message)` 模型 + `asset_poll_status_view`。
|
||||
|
||||
**接线步骤(给你的 Claude):**
|
||||
1. `requirements.txt` 加 `volcengine` SDK 并安装(**当前 AirShelf 没装**)。
|
||||
2. 照搬 `assets_client.py` 到 `apps/assets/`,改 import。
|
||||
3. 加 `AssetGroup` / 给 `Asset` 加 `remote_asset_id/audit_status/audit_error` 字段 + 迁移。
|
||||
4. 生成人物基础资产后**静默自动上传**到团队素材组,前端轮询 audit_status 渲染绿/红标。
|
||||
5. env 已备好:`ASSETS_API_ACCESS_KEY / ASSETS_API_SECRET_KEY / ASSETS_API_ENABLED / ASSETS_API_PROJECT_NAME`。
|
||||
|
||||
---
|
||||
|
||||
## 7. 凭证与 .env(⚠️ 一处待换)
|
||||
|
||||
`.env` 已含可用凭证(已随 dev 推送,沿用本仓库既有「.env 入库」惯例):
|
||||
- `TOKENSSR_API_KEY / TOKENSSR_BASE_URL` — 中转站主力(一把 key 通吃 ~90 模型)。
|
||||
- `FEISHU_APP_ID / FEISHU_APP_SECRET` — 小毛球机器人(交接推送)。
|
||||
- `VOLC_TTS_*` — 豆包语音合成(可选后期配音)。
|
||||
|
||||
**🔴 张业昌待办(唯一硬待办):** `ASSETS_API_ACCESS_KEY / ASSETS_API_SECRET_KEY` 现在**暂借 AirDrama 已邀测开通的火山 AK/SK** 跑通。AirShelf 自有火山账号开通「人像素材库」后,**只换这两行**(独立于 `TOS_*`,不要动 TOS)。
|
||||
|
||||
---
|
||||
|
||||
## 8. 给前端 UX 的 2 天打磨建议(视觉,非阻塞)
|
||||
|
||||
当前以「流程跑通 + 真 agent 体感」为先,视觉是够用级,可优化:
|
||||
- 脚本助手的**工具卡**现在复用进度流气泡渲染(`ProgressStream` 的 steps),可做成独立「工具卡」样式(图标 + 标题 + 状态点),更像 lovart。
|
||||
- 思考前言文字现在是 `opacity:0.85` 的普通段落,可加打字机光标 / 渐显。
|
||||
- 分镜卡可加 **role 徽章**(钩子/痛点/卖点/CTA,数据已在 `segment.role`)和商品露出标签(`segment.product_exposure`)。
|
||||
- 模型下拉复用了 `.setup-select`,可做成带模型图标的 Pill。
|
||||
- **务必遵循** `电商AI平台/design.md`(冷灰底/单橙锚点/8px 圆角/inside-border),别新造色值或重写共享类。
|
||||
|
||||
---
|
||||
|
||||
## 9. 已知问题 / TODO
|
||||
|
||||
- 流式生成中途客户端断开,可能留下「预扣未结算」额度(边缘情况;`stream_script_agent` 已尽量 try/except 释放,但 WSGI 断流的 finally 不保证)。
|
||||
- vite 代理转发 SSE 一般不缓冲;生产 nginx 需确认关掉 `proxy_buffering`(响应已带 `X-Accel-Buffering: no`)。
|
||||
- 计费仍是「每次调用扣 `unit_price`」,非真 token 计量。
|
||||
- 故事板 @图N、视频出音 建议接 worker 后端到端复跑一轮确认。
|
||||
|
||||
---
|
||||
|
||||
## 10. 本批改动文件清单
|
||||
|
||||
**后端新增:** `providers/openai_compatible.py`、`script_agent.py`、`model_library.py`、`management/commands/seed_demo_models.py`、迁移 `ai/0005`、`ai/0006`、`projects/0002`。
|
||||
**后端修改:** `providers/volcano.py`(流式+音频)、`providers/__init__.py`、`ai/models.py`(ModelProvider.api_key)、`ai/services.py`(provider 分流+故事板参考图)、`projects/models.py`(ScriptSegment 字段)、`projects/serializers.py`、`projects/views.py`(SSE 端点+renderer)、`settings/base.py`(TOKENSSR/PROVIDER_KEYS)、`.env`。
|
||||
**前端修改:** `api.ts`(agentScriptStream)、`routes/pipeline.tsx`(SSE 驱动+模型下拉+改稿)、`App.tsx`(textModels)、`types.ts`。
|
||||
**资源:** `skills/ecommerce-video-script/`(运行时依赖,**必须随仓库**)。
|
||||
|
||||
设计 SSoT:`AI生成-Agent化落地方案.md`。API 参考:`tokenssr接口文档.md`、`image调用参考.md`。
|
||||
|
||||
---
|
||||
|
||||
_有疑问看 `_qa_shots/` 的截图(脚本流式三连 + 模特三视图),或直接复跑 §0。祝接手顺利。_
|
||||
Reference in New Issue
Block a user