docs: 新增分镜→时间线→模型语言 pipeline 设计方案

独立纯函数包 video/pipeline/:重建时间线+回写对齐、prompt 实体名→token 替换、按可配置 schema 组装完整视频 API 请求体。参考 video-factory 实现,方案 B 从零设计。

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
2026-08-11 11:29:14 +08:00
co-authored by Claude Opus 4.7
parent cb9510c13b
commit 9b23f69d9c
@@ -0,0 +1,226 @@
# 分镜 → 时间线 → 模型语言 Pipeline 设计
日期:2026-08-11
状态:已评审(用户确认)
范围:ai-agent 新增独立纯函数包 `video/pipeline/`
## 1. 背景与动机
`script_transcribe` 节点产出 `[{"shots":[...]}]` 后,视频生成需要把分镜镜头转成视频模型能理解的输入。video-factory 项目已有完整链路:**时间线分段 → prompt(实体名→characterN 替换)→ 按 schema 嵌套的完整视频 API 请求体**。
ai-agent 现有 `video/plan/` 已覆盖"重建时间线 + 拆段 + 中文 prompt + 扁平 modelCall 参数",但与 video-factory 存在差异,且缺少三件事:
1. **时间线口径**:现有 `NormalizeShots` 丢弃 AI 时间戳、按台词字数重建时长(保留此口径,用户选定"重建但回写对齐");
2. **跨边界镜头不切分**:跨段边界的镜头原样保留、时间码不回写对齐(本方案改为按段边界切分 + 短残片并入);
3. **模型语言转换缺失**
- prompt 内实体名未替换成 `characterN` 等 token`reference_labels` 单独传,靠模型自行理解);
- 无按 schema 嵌套的完整视频 API 请求体(`video-factory``BuildSchemaRequest` 等价物)。
用户决策:**新流程从零设计**,做成**独立纯函数包**(不依赖 `plan`、不碰 workflow、零 I/O),**外部直接传 shots**,目标视频 API **先做通用抽象**schema 可配),模型语言覆盖 **prompt 文本层 + 完整请求体层**
## 2. 目标与非目标
### 目标
- 提供一个纯函数入口:`shots → 时间线(重建+回写对齐)→ 分段 → prompt(token 替换)→ 完整视频 API 请求体`
- 通用抽象:模型 schema 可配置,未配置时用默认火山风格结构
- 可独立单测,零 I/O、零 workflow 依赖
### 非目标(本阶段不做)
- 不接入 workflow 节点 / 前后置处理器(后续可写薄适配层)
- 不调用模型网关 / 媒体服务(`video/plan``video/` 根包负责)
- 不做视频合并(concat/merge)、不做首帧联动(serial
- 不重构现有 `video/plan/``script_transcribe`
## 3. 包结构与依赖
- 新包:`ai-agent/video/pipeline/`
- 依赖:仅 `ai-agent/video/domain`(复用 `domain.Shot` 作为输入契约)+ Go 标准库(`encoding/json``strings``fmt` 等)
- **不 import** `plan`、workflow、gateway、media 等
- 内部自带:时间线重建、超长镜头切分、段时长计算、回写对齐、prompt 构建、schema 请求体构建
文件规划(建议):
```
video/pipeline/
pipeline.go // Input/Segment/Output 契约 + Run 编排入口
timeline.go // ① 时间线构建:rebuildDurations / splitOversized / calcSegmentDurations / alignToSegments
prompt.go // ② prompt 构建:实体名→token 替换、截断
request.go // ③ 请求体构建:schema 嵌套、默认 schema、media 组装
*_test.go // 各阶段单测 + 用户样本 golden test
```
## 4. 数据契约
```go
// Input 外部直接传入的生成请求参数。
type Input struct {
Shots []domain.Shot // 镜头脚本(中文键或英文键均可,调用方已归一为 domain.Shot
TotalDuration int // 目标总时长(秒),<=0 按镜头时间码累加兜底,仍<=0 默认 60
MaxSegmentDur int // 单段最大时长(秒),<=0 默认 15
MinSegmentDur int // 单段最小时长(秒),<=0 默认 5
Refs Refs // 参考素材(角色/场景/道具/产品,具名)
Seed int64 // 随机种子基数,各段 = Seed + 段序号
NegativePrompt string
ModelName string // 写入请求体 body.model
Schema map[string]any // 视频模型 schema(可选),nil 用默认通用结构
MaxRefs int // 参考素材上限,<=0 默认 5
MaxPromptChars int // prompt 截断长度(rune),<=0 不截断
TokenConfig TokenConfig // 实体名替换 token 的生成配置
}
// TokenConfig token 前缀策略:可配置,默认全用 characterNvideo-factory 兼容)。
type TokenConfig struct {
// 是否按类别区分前缀。false=一律 characterN(推荐,模型只按 reference_urls 顺序识别);
// true=character/scene/prop/product 分别编号。
ByCategory bool
// 自定义前缀模板,如 "char%d"。为空时按 ByCategory 决定默认行为。
Template string
}
// Refs 参考素材,与 domain/plan 的 Refs 概念一致(自包含定义,不依赖 plan)。
type Refs struct {
Characters []RefItem `json:"characters,omitempty"`
Scenes []RefItem `json:"scenes,omitempty"`
Props []RefItem `json:"props,omitempty"`
Products []RefItem `json:"products,omitempty"`
}
type RefItem struct {
Name string `json:"name"`
URL string `json:"url"`
}
// Segment 一个视频生成段:时间轴、段内镜头、prompt、参考素材、请求体。
type Segment struct {
Index int // 段序号(从 0 起)
StartSec int // 段在全局时间轴上的起点(秒)
Duration int // 段时长(秒)
Shots []domain.Shot // 段内镜头(已回写对齐,不跨段)
Prompt string // 实体名→token 替换后的 prompt 文本
Labels map[string]string // 实体名 → tokencharacter1...
RefURLs []string // 参考素材 URL,顺序与 token 对应
Seed int64
Request map[string]any // 完整视频 API 请求体(schema 嵌套)
}
// Output Run 的产出。
type Output struct {
Segments []Segment
TotalDuration int // 实际总时长(秒)
}
```
## 5. 阶段① 时间线构建(重建 + 回写对齐)
入口:`BuildTimeline(in Input) (segShots [][]domain.Shot, segDurs []int, err error)`
`segShots[i]` 为第 i 段的已对齐镜头列表,段数量与 `segDurs` 一致)
### 5.1 rebuildDurations
丢弃 AI 的 `startTime/endTime`,按镜头文本量重建每段时长:
- 有文字(台词+旁白)的镜头:按语速(默认 4 字/秒,感叹/疑问多→6,低落→3)算最小时长 = `ceil(字数/语速) + 1`,低弹性;
- 纯视觉镜头:以 AI 原始时长意图为基准(`endTime-startTime`),高弹性;
- 盈余按弹性权重分配;超出目标则先压缩视觉镜头、再等比压缩有声镜头、最后截断尾部;
- 不变量:重建后各镜头时长之和 **精确等于** `TotalDuration`
> 算法与 `video/domain/timeline.go` 的 `rebuildTimeline` 语义一致,但在 `pipeline` 内**自包含实现**(不 import plan)。
### 5.2 splitOversized
`MaxSegmentDur` 的镜头按句末断句(`。!?;\n!?.`)切子镜头,避免"说话说一半";最后一片段 `≤ minSeg` 时减少拆分段数。重排 `index`
### 5.3 calcSegmentDurations
`TotalDuration` 拆为多段:
- `numSegments = ceil(Total / MaxSegmentDur)``base = Total / num`,前 `Total % num` 段各 `+1`
- 每段 `≥ MinSegmentDur`(不足时从后一向前借位);
- 边界:`MinSegmentDur > MaxSegmentDur` 时收敛为相等。
### 5.4 alignToSegments(回写对齐,核心新增)
按段边界把镜头切分并对齐:
1. 以段边界 `segStart`/`segEnd`(秒)为切点;
2. 对每个与段窗口相交的镜头,裁剪到 `[max(shotStart, segStart), min(shotEnd, segEnd))`,时间码写回为段内绝对时间轴上的 `MM:SS`
3. **跨边界镜头切成两个子镜头**,各留各段,剩余部分进入下一段的时间线;
4. **短残片并入**:切出的子镜头时长 `< 1s` 时并入相邻段(并入前一段末尾),避免产生超短镜头;
5. 不变量:每个镜头只属于一个段;段内镜头时间码落在该段窗口内;所有段首尾相接覆盖 `[0, TotalDuration)`
输出:`segShots [][]domain.Shot`(每段一组已对齐镜头)+ `segDurs`,供阶段②逐段构建 prompt。
## 6. 阶段② Prompt 构建(模型语言·文本层)
入口:`BuildSegmentPrompt(segShots []domain.Shot, refs Refs, tc TokenConfig, maxRefs int) (prompt string, labels map[string]string, refURLs []string)`
1. **收集实体**:按 演员→场景→道具(→产品,若产品图固定携带)出现顺序收集段内具名实体,查 `refs` 拿 URL,上限 `MaxRefs`
2. **生成 token**:按 `TokenConfig` 生成 `character1...`(默认,全用同一前缀、按引用顺序编号)或按类别前缀 `character1/scene1/prop1/product1`,或自定义模板;
3. **替换 prompt 文本**:把镜头文本中的实体名替换为 token;**按名字长度降序替换**,避免长名是短名前缀时的部分覆盖;无 URL 的实体保留原名;
4. **截断**`MaxPromptChars > 0` 且超出时截断并追加 `...`(保留至少 50 字符下限);
5. 返回 `prompt`(替换后)、`labels`(实体名→token)、`refURLs`(顺序与 token 对应)。
## 7. 阶段③ 请求体构建(模型语言·API 层)
入口:`BuildVideoRequest(seg Segment, in Input) map[string]any`
1. 组装 flat input
```go
map[string]any{
"prompt": seg.Prompt,
"seed": seg.Seed,
// 以下有值才写
"negative_prompt": in.NegativePrompt,
"duration": seg.Duration,
"media": buildMedia(seg.RefURLs, mediaTypeValue), // 参考图 media 数组
"reference_urls": seg.RefURLs,
}
```
2. **schema 嵌套**:仿 video-factory `BuildSchemaRequest`——遍历 schema,无 `type` 键的节点视为分组递归进入,有 `type` 的字段从 flat input 取值、缺失填 `default`、可校验 required/type`in.Schema == nil` 时用默认通用结构;
3. `body["model"] = in.ModelName`
4. media 组装:参考图 URL 包装为 media 项,type 值取 `reference_image`schema_mapping 可配置,复用路径解析思路);媒体在请求中保持 URL 字符串(base64 转码属 I/O,不在本包做,由调用方处理)。
### 默认通用 schema(火山风格)
```json
{
"body": {
"input": {
"prompt": { "type": "string", "required": true },
"seed": { "type": "integer", "default": -1 },
"negative_prompt": { "type": "string" },
"duration": { "type": "integer" },
"media": { "type": "array" },
"reference_urls": { "type": "array" }
},
"parameters": {
"model": { "type": "string" }
}
}
}
```
## 8. 入口
```go
// Run 编排①→②→③,纯函数,无 I/O。
func Run(in Input) (*Output, error)
```
错误处理:
- `Shots` 为空 → 返回错误(不产出空请求);
- `TotalDuration <= 0` 且镜头无法累加时长 → 兜底 60;
- schema 字段校验失败 → 返回明确错误。
## 9. 测试计划
| 用例 | 断言 |
|------|------|
| 时间线重建恒等 | 重建后镜头时长之和 == TotalDuration |
| 跨边界切分回写 | 段边界处的镜头被切成子镜头,各镜头时间码落在所属段窗口内,不跨段 |
| 短残片并入 | 切出的 `<1s` 子镜头并入相邻段 |
| token 替换 | 实体名替换为 character1...;长名优先替换;无 URL 实体保留原名 |
| token 可配置 | ByCategory=true 时按类别前缀;Template 生效 |
| schema 嵌套/默认值 | flat input 正确嵌套进 `{body:{input,parameters}}`,缺失字段填 default |
| 用户样本 golden test | 用 2026-08-11 实测的 3 镜头中文样本(时间码 00:00-00:07 等)跑通 Run,断言段数量、prompt 含 characterN、请求体结构 |
## 10. 与现有链路的关系(本阶段不实现)
- 新包完全独立,不接 workflow;
- 将来接入:写薄适配层把 `Segment.Request` 交给模型网关 `modelCall`(对应 `video/plan` 的 `SegmentParamsToMap` 语义),媒体 URL 转 base64 在适配层做;
- 现有 `split_shots`/`generate_segments_serial` 保持不动,两者可并存。
## 11. 评审记录
- 2026-08-11:方案 B(完全独立从零写)获用户确认;token 前缀=可配置(默认 characterN);跨边界=切分+短残片并入;目标 API=通用抽象;挂载层=独立纯函数包;输入=外部直接传 shots。