第36章:剪映草稿流水线总览
从本章起进入成片阶段。internal/draft 包把"从 clips 到剪映草稿"的完整过程组织为四个协作角色:Request/Result(与任务模型解耦的入参出参)、Pipeline(素材准备)、Recipe/Steps(有序组装步骤)、Session(贯穿全程的可变状态)。本章先建立全局图景,后三章分别深入。
流程
Builder.Build 是流水线的总指挥:解析 clips(入参直给或从 project 的 clips1/clips0 反推)→ 500ms 合并相邻片段 → 校验 → 构造 Session → Prepare 下载裁剪 → 按顺序执行 Recipe 的三个 Step → 校验 draft_url → 打包上传切片 tar。
flowchart TD
A[Request<br/>JobID/Material/Project/Clips] --> B[Build]
B --> C{Clips 为空?}
C -- 是 --> D[ResolveClipRanges<br/>从 clips1/clips0 解析]
C -- 否 --> E[直接使用]
D & E --> F[MergeAdjacentClipRanges<br/>间隙≤500ms 合并]
F --> G[ValidateClipRanges]
G --> H[构造 Session<br/>含 NewTimeline 空时间线]
H --> I[Prepare.Run<br/>下载源视频+ffmpeg 裁剪]
I --> J[Step1 CreateStep<br/>创建剪映草稿]
J --> K[Step2 VideosStep<br/>上传切片+铺视频轨]
K --> L[Step3 CaptionsStep<br/>按 ASR 铺字幕]
L --> M{draft_url 非空?}
M -- 是 --> N[PackAndUploadClipsTar<br/>失败不阻断]
N --> O[Result: DraftURL + ClipsTarURL]
M -- 空 --> P[报错]
实现
分层解耦是这个包最大的特点。Request 的注释明确"与任务模型无关"——JobID 只用于日志与目录命名,不强制是 task id;Progress 是一个 func(local int16) 回调,任务层负责把 0~100 的本地进度映射到全局 task.progress(独立任务直接透传,一键成片按阶段区间缩放)。Generator 接口因此可以被任何调用方使用:独立 draft Worker、一键成片 Worker、甚至未来的手动重放工具。
Recipe 的编译期安全:DefaultRecipe 返回三步——CreateStep → VideosStep → CaptionsStep,每步自持依赖(API client、Uploader、Logger),Recipe.Steps 为空时回落默认。Builder 尾部的 var _ steps.Step = steps.CreateStep{} 是接口实现断言,把"某步忘了实现 Step 接口"从运行期错误提前到编译期。
收尾的降级语义:PackAndUploadClipsTar 失败只记 Error 日志、ClipsTarURL 留空——草稿本体已成功,附带产物(供用户单独下载切片的 tar 包)不应拖垮主结果。而 s.DraftURL == "" 则是硬失败:三步全跑完却没产出 URL,说明步骤实现有 bug,宁可报错也不返回空壳成功。
NewGenerator 的依赖兜底链:Cutter 缺省回落 media.NewFFmpegConverter(""),Downloader 缺省走工厂函数——组合根可以全量注入,单测可以只注入关心的部分,其余落默认。
📌 设计决策
- 准备与组装两段式:Prepare 产出本地文件(
ClipPaths),Steps 消费本地文件并调用远端 API。文件系统是两段之间的"接口",让 ffmpeg 重活与 HTTP 调活可独立测试与重试。 - Session 作为共享黑板:所有 Step 读写同一个 Session(草稿 ID、时间线、clip 路径),避免步骤间参数传递链条爆炸;代价是步骤执行顺序隐含数据依赖,必须由 Recipe 保证。
- tar 包失败不阻断:附带产物与主产物的失败域隔离;用户仍能打开草稿,tar 可后续手动补。
- Progress 回调而非进度对象:函数类型比接口更轻,任务层闭包捕获 task ID 即可,draft 包对"进度写到哪"零知识。
代码示例
片段 1 展示流水线总指挥(摘自 draft/builder.go,节选):
// Build 解析 clips → Prepare → 按 Recipe 执行 Steps,返回 draft_url。
func (b *Builder) Build(ctx context.Context, req Request) (*Result, error) {
clips := req.Clips
if len(clips) == 0 {
if req.Project == nil {
return nil, fmt.Errorf("clips 为空且未提供 project")
}
var err error
clips, err = prepare.ResolveClipRanges(req.Project)
if err != nil {
return nil, err
}
}
beforeMerge := len(clips)
clips = prepare.MergeAdjacentClipRanges(clips, prepare.ClipMergeGapMS)
b.Logger.Info("草稿裁剪前合并相邻片段",
zap.String("job_id", req.JobID),
zap.Int("clips_before", beforeMerge),
zap.Int("clips_after", len(clips)),
)
if err := prepare.ValidateClipRanges(clips); err != nil {
return nil, err
}
recipe := req.Recipe
if len(recipe.Steps) == 0 {
recipe = DefaultRecipe(b.API, b.Uploader, b.Logger)
}
s := &session.Session{
JobID: req.JobID, Project: req.Project, Material: req.Material,
StagingDir: req.StagingDir, RecordDir: req.RecordDir,
Clips: clips, CanvasW: req.CanvasW, CanvasH: req.CanvasH,
Timeline: session.NewTimeline(), Progress: req.Progress,
}
if err := b.Prepare.Run(ctx, s); err != nil {
return nil, err
}
for _, step := range recipe.Steps {
if err := step.Run(ctx, s); err != nil {
return nil, fmt.Errorf("步骤 %s 失败: %w", step.Name(), err)
}
}
if s.DraftURL == "" {
return nil, fmt.Errorf("草稿组装完成但 draft_url 为空")
}
result := &Result{
DraftURL: s.DraftURL}
// 将本地 clip_XXX.mp4 打成 {jobID}.tar 并上传;失败仅记日志,不阻断草稿成功。
clipsTarURL, err := PackAndUploadClipsTar(ctx, b.Uploader, s.StagingDir, s.JobID, s.ClipPaths)
if err != nil {
b.Logger.Error("切片 tar 打包上传失败", zap.String("job_id", s.JobID), zap.Error(err))
} else {
result.ClipsTarURL = clipsTarURL
}
return result, nil
}
片段 2 展示契约类型与默认 Recipe(摘自 draft/request.go 与 draft/recipe.go):
// Request 剪映草稿组装入参(与任务模型无关)。
type Request struct {
JobID string // 仅用于日志/目录命名,不强制是 task id
Material *model.LiveMaterial
Project *model.VideoProject
// Clips 为空时,若 Project 非空则从 clips1/clips0 解析并合并相邻片段。
Clips []model.ClipRange
CanvasW, CanvasH int
StagingDir string
RecordDir string
// Progress 可选:本地进度 0–100;由任务层映射到 task.progress。
Progress func(local int16)
// Recipe 为空时使用 DefaultRecipe。
Recipe Recipe
}
// Recipe 有序组装步骤列表。
type Recipe struct {
Steps []steps.Step
}
// DefaultRecipe 默认流水线:创建草稿 → 上传切片并添加主视频轨 → 按 ASR 添加同步字幕。
func DefaultRecipe(api CapCutMateAPI, uploader steps.ObjectUploader, logger *zap.Logger) Recipe {
return Recipe{
Steps: []steps.Step{
steps.CreateStep{
API: api, Logger: logger},
steps.VideosStep{
API: api, Uploader: uploader, Logger: logger},
steps.CaptionsStep{
API: api, Logger: logger},
},
}
}
小结
draft 包用"入参出参解耦 + 两段式流水 + 有序步骤 + 共享 Session"四件套,把一段混杂 ffmpeg、对象存储、远端 HTTP 的复杂流程驯服为可测试、可复用、可编排的纯组件。下一章进入 Prepare 的裁剪细节。
思考题
- 若在 Recipe 中插入一个
MusicStep(添加背景音乐),它对 Session 需要哪些新字段?时间线结构是否需要扩展? Build在 Step 失败后直接返回,不做草稿清理——capcut-mate 侧会留下半成品草稿。你会如何设计补偿机制(回滚 API / 标记孤儿草稿定期清理)?
项目信息
- GitHub仓库:Chyona/live-mixer