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:
@@ -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 前缀策略:可配置,默认全用 characterN(video-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 // 实体名 → token(character1...)
|
||||
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。
|
||||
Reference in New Issue
Block a user