第11章:剪辑项目模型 VideoProject
本章深入 internal/model/video_project.go:一个剪辑项目如何把「素材 + 提示词 + 选段 + 画布 + 元数据」打包成一张加工单。核心是 clips0(粗选区间)与 clips1(精修选段)的双层选段设计,以及短视频标题/描述/话题的约束常量族。
流程
剪辑项目是 AI 与人工协同的交界面。创建时用户提供 name、live_id(关联素材,需其 ASR 已完成)、prompt_id(提示词,默认 1);可选携带 clips0 粗选区间(人工圈定「值得 AI 关注的大范围」)。此后 clips 的流转有两条路:
- 一键成片:AI 切片阶段读取 clips0(空则全片)+ 提示词 + ASR 段落 → LLM 挑选句段 → 直接回写 clips1(带文本与词级时间戳)→ 草稿阶段裁剪生成。
- 协同模式:AI 切片回写 clips1 后任务结束 → 人工在前端调整 clips1(增删改区间、改正文本)→ 手动发起草稿任务。
画布 Width/Height 决定剪映工程分辨率(0 表示未设置,创建任务时按素材分辨率自动补齐);EnableCaptions 控制草稿是否加字幕轨;Title/Description/Topics 三个字段在 AI 切片成功后由 Worker 从 LLM 结果回写,作为短视频发布时的元数据(前端发布面板可直接复用)。
输入输出视角:项目输入是「素材 ID + 提示词 ID + 可选粗选」,输出是「clips1 + 元数据」,中间被任务驱动演进。项目本身不存任务状态------运行中任务通过 GET /video-projects/:id/running-tasks 实时查询。
实现
双层选段的设计意图:
- clips0 \[\]ClipRange:只有毫秒区间,无文本。粗筛粒度(分钟级),表达「这段直播值得看」。
- clips1 \[\]ClipWithText:带逐句文本与可选词级时间戳。精修粒度(秒级),是草稿裁剪与字幕的直接数据源。词级 words 由 AI 切片 Worker 从 ASR 段落的 words 中截取对应时间窗写入,字幕对齐因此能精确到字。
约束常量族把产品规则写成代码(按 Unicode 码点计数,避免「汉字一个字算仨字节」的坑):
go
VideoProjectTitleMinRunes = 2 // 标题 2~12 字
VideoProjectDescriptionMaxRunes = 128 // 描述上限
VideoProjectTopicMinRunes = 2 // 话题每个 2~12 字
VideoProjectTopicsMaxCount = 6 // 共 2~6 个
这些常量与 AI 切片的提示词输出格式(第 36 章的 aiSliceUserPromptOutputFormat)严格对齐------LLM 被要求按同样约束产出,Worker 校验再回写,模型输出与数据契约由同一组常量约束。
其他实现细节:
DefaultVideoProjectPromptID = 1:种子数据保证 ID=1 的提示词存在(第 14 章),新建项目缺省即有可用提示词。ProjectSource字段记录项目来源(如手动创建 / 导入),为多入口创建留审计痕迹。PromptID 无外键约束(注释明示):删提示词不级联,service 层换提示词时校验存在性。- 列表视图
VideoProjectListItem只含摘要字段,巨大的 clips JSONB 不进列表页。
📌 设计决策
- 双层 clips 而非一层带标记:粗选与精修语义不同(范围 vs 句段),类型系统直接区分(ClipRange vs ClipWithText),避免同一结构里塞可空字段。
- 元数据回写到项目而非仅存任务:一次 AI 切片产出可服务多次草稿生成与人工微调后重发,避免「重新切片才能改标题」。
- 画布尺寸存在项目上、任务创建时快照到 task:草稿任务可被请求参数覆盖画布,项目值只是默认来源(见第 12 章 task.Width 注释)。
代码示例
项目实体全貌(节选注释):
go
// internal/model/video_project.go
type VideoProject struct {
ID uint `gorm:"primaryKey" json:"id"`
Name string `gorm:"size:64;uniqueIndex" json:"name"`
LiveID uint `gorm:"column:live_id;not null;index;comment:关联直播素材ID" json:"live_id"`
// PromptID 提示词 ID(对应 llm_system_prompt.id),无外键约束,默认 1。
PromptID uint `gorm:"column:prompt_id;not null;default:1;index" json:"prompt_id"`
Clips0 []ClipRange `gorm:"column:clips0;serializer:json;type:jsonb;not null;default:'[]';comment:视频切片列表毫秒" json:"clips0"`
Clips1 []ClipWithText `gorm:"column:clips1;serializer:json;type:jsonb;not null;default:'[]';comment:带文本与词级时间戳的切片列表毫秒" json:"clips1"`
Width int `gorm:"not null;default:0;comment:剪映草稿工程宽度像素" json:"width"`
Height int `gorm:"not null;default:0" json:"height"`
EnableCaptions int `gorm:"column:enable_captions;not null;default:1;comment:是否添加字幕0否1是" json:"enable_captions"`
Title string `gorm:"size:64;not null;default:'';comment:短视频标题" json:"title"`
Description string `gorm:"size:512;not null;default:''" json:"description"`
Topics []string `gorm:"column:topics;serializer:json;type:jsonb;not null;default:'[]'" json:"topics"`
ProjectSource string `gorm:"column:project_source;size:32;not null;default:''" json:"project_source"`
}
草稿流水线消费 clips 的入口(第 38 章详述):
go
// internal/draft/builder.go(节选)
clips := req.Clips
if len(clips) == 0 {
if req.Project == nil {
return nil, fmt.Errorf("clips 为空且未提供 project")
}
clips, err = prepare.ResolveClipRanges(req.Project) // 从项目解析 clips
if err != nil {
return nil, err
}
}
// 合并间隔 ≤500ms 的相邻片段,减少 ffmpeg 调用与草稿碎片
clips = prepare.MergeAdjacentClipRanges(clips, prepare.ClipMergeGapMS)
小结
- 项目 = 素材 + 提示词 + 双层选段 + 画布 + 发布元数据的聚合体,是 AI 与人工的协作界面。
- clips0 管范围、clips1 管句段,类型即语义;词级时间戳让字幕精确到字。
- 约束常量族同时约束 LLM 提示词与数据校验,单一事实源。
思考题
- clips1 的人工编辑与 AI 重跑同时发生时,如何设计「保留人工修改」的合并策略?
- Title/Topics 回写项目后,多次切片会互相覆盖,是否应改为「每次任务一份元数据」?
项目信息
- GitHub仓库:github.com/Chyona/live-mixer
- 项目案例:gogoshine.com