AI 分段引擎与自动剪辑决策:从语义片段到时间线草稿的映射
"一键成片"听起来像营销话术,但拆开看它是一条严密的工程链路:AI 把内容切成若干语义片段(Segment),每个片段携带口播稿、转写文本、音频路径、字幕外键、推荐素材、绑定剪辑六类信息,再由一个确定性映射器把它们"翻译"成时间线上的轨道与 Clip。本文拆解这条链路的核心------AI 分段的数据建模、外键联动的一致性设计、以及从"建议"到"可编辑草稿"的确定性落盘。
一、为什么 AI 剪辑需要"分段"这个中间表示
直接让大模型输出"时间线"是最直觉的设计,但它是错的:
- 时间线是低级表示:轨道、Clip、sourceIn/sourceOut 是渲染引擎的词汇表,LLM 对秒级数字的估算误差极大,直接生成会产出大量越界、重叠、外键悬空的非法状态。
- 不可控:模型每次输出的 Clip 数量、顺序、时长都漂移,用户无法增量修改------只能整体接受或整体丢弃。
- 无法回溯:时间线里看不出"哪一段是 AI 建议的、依据是什么"。
正确做法是在"AI 输出"和"时间线"之间插一层中间表示(Intermediate Representation)------语义分段。每个分段是一个自包含的语义单元:
ts
const aiSegmentSchema = z.object({
id: z.string().min(1),
scriptText: z.string().min(1), // 口播稿:这一段"要说什么"
transcriptText: z.string().optional(), // 转写文本:原素材"实际说了什么"
ttsAudioPath: z.string().min(1).optional(), // TTS 合成音频落盘路径
subtitleIds: z.array(z.string()), // 外键:关联的字幕条目
recommendedAssetIds: z.array(z.string()), // 外键:AI 推荐的素材
boundClipIds: z.array(z.string()) // 外键:已绑定到时间线的 Clip
})
六个字段恰好构成一条从意图到成片的流水线 :scriptText(意图)→ transcriptText(对齐原素材)→ ttsAudioPath(语音物化)→ subtitleIds(字幕落地)→ recommendedAssetIds(素材推荐)→ boundClipIds(剪辑绑定)。AI 只负责填这张"语义卡片",时间线的组装交给确定性代码。
二、外键联动:AI 输出也要过引用完整性检查
分段不是孤立数据------subtitleIds 指向字幕表,boundClipIds 指向剪辑表,recommendedAssetIds 指向素材表。任何一处外键悬空,UI 就会出现"点击无响应"或"删除后幽灵卡片"。
工程做法:把分段的跨表约束并入 document 级 superRefine,与 clip 的外键检查平级:
ts
.superRefine((doc, ctx) => {
const subtitleIds = new Set(doc.subtitles.map(s => s.id))
const assetIds = new Set(doc.assets.map(a => a.id))
const clipIds = new Set(doc.clips.map(c => c.id))
doc.aiSegments.forEach((seg, i) => {
for (const sid of seg.subtitleIds) {
if (!subtitleIds.has(sid)) {
ctx.addIssue({
code: 'custom',
message: `aiSegment ${seg.id} references missing subtitle: ${sid}`,
path: ['aiSegments', i, 'subtitleIds'],
})
}
}
for (const aid of seg.recommendedAssetIds) {
if (!assetIds.has(aid)) { /* dangling assetId */ }
}
for (const cid of seg.boundClipIds) {
if (!clipIds.has(cid)) { /* dangling clipId */ }
}
})
})
更关键的是级联删除语义。用户删掉一条字幕时,引用它的分段怎么办?三种策略:
| 策略 | 行为 | 适用 |
|---|---|---|
| RESTRICT | 阻止删除,提示"先解除分段关联" | 字幕是分段核心依据时 |
| CASCADE | 级联清空分段里的 subtitleIds 项 |
引用是弱关联时(推荐) |
| SET NULL | 置空整个分段 | 分段完全失效时 |
视频编辑器里字幕通常只是分段的"展示附件",选 CASCADE------删除字幕时从各分段的 subtitleIds 里移除该 ID,分段本身保留。这与 removeTrack 级联删除其下所有 clips 是同一个设计思想:删除操作的连带范围必须在 schema/纯函数层显式定义,而不是散落在各个 UI 回调里。
三、确定性映射器:AI 建议 → 时间线草稿
分段到时间线的转换必须是一个纯函数------同样的分段输入,永远产出同样的补丁序列。这是"AI 结果可编辑、可撤销"的基石:
ts
function segmentsToPatch(
doc: ProjectDocument,
segments: AiSegment[],
): TimelinePatch {
const operations: TimelinePatchOperation[] = []
// 1. 确保 voiceover 轨道存在(幂等:已存在则跳过)
const voiceTrack = doc.tracks.find(t => t.type === 'voiceover')
const voiceTrackId = voiceTrack?.id ?? `track-voiceover-${genId()}`
if (!voiceTrack) {
operations.push({
op: 'addTrack',
value: {
id: voiceTrackId, type: 'voiceover', name: 'AI 口播',
locked: false, muted: false, visible: true,
order: doc.tracks.length,
},
})
}
// 2. 每个分段生成一条字幕 + 一条口播 Clip,时间轴顺序排布
let cursor = 0
for (const seg of segments) {
const dur = estimateDuration(seg.scriptText) // 按字数/语速估算
const subId = `sub-${genId()}`
operations.push({
op: 'addClip', // 字幕在 text 轨:简化示例,实际可加 addSubtitle op
value: { /* ... */ },
})
if (seg.ttsAudioPath) {
operations.push({
op: 'addClip',
value: {
id: `clip-${genId()}`,
assetId: ensureAsset(doc, seg.ttsAudioPath), // 音频 asset 注册
trackId: voiceTrackId,
start: cursor, duration: dur,
sourceIn: 0, sourceOut: dur,
volume: 1, speed: 1,
transform: IDENTITY_TRANSFORM,
},
})
}
cursor += dur
}
return { patchId: genId(), projectId: doc.projectId, operations, document: doc, projectPath: doc.projectPath }
}
三个设计要点:
- 幂等性:重复调用不会产生重复轨道/重复 Clip------先查再建,已存在则复用 ID。
- 确定性 :不调用 AI、不读时钟(除
updatedAt)、不产生随机排列。用户撤销后重做,得到一样的草稿。 - 补丁化 :产出的是 op 列表而非直接改 document,走统一的
applyTimelinePatch校验管线,AI 输出和用户手动操作最终汇入同一条状态变更通道------AI 不是特权调用方。
四、估算与校准:时长是第一杀手
AI 只知道文字,不知道 12 字的中文口播在 1.05x 语速下实际是 4.2 秒还是 5.8 秒。分段落盘时的 estimateDuration 决定了草稿的"第一印象"精度:
ts
// 中文按 4.2 字/秒(1x 语速),英文按 2.8 词/秒,再乘语速修正
function estimateDuration(text: string, speed: number): number {
const cjk = (text.match(/[\u4e00-\u9fff]/g) ?? []).length
const latin = (text.match(/[a-zA-Z]+/g) ?? []).length
const base = cjk / 4.2 + latin / 2.8
const withPadding = base + 0.4 // 首尾静音 padding
return Math.max(0.5, withPadding / speed)
}
但估算终究会被 TTS 实际音频时长推翻。因此流水线设计成两阶段:
- 草稿阶段:用估算时长占位,时间线上先能看到整体结构。
- 校准阶段 :TTS 音频落盘后,用
ffprobe读真实时长,发一个updateClipop 把占位时长替换为真实值。此时若与估算差超过阈值(如 15%),顺带平移后续所有 Clip 的start------这就是"AI 排版自动重排"。
ts
async function calibrateSegmentDurations(doc: ProjectDocument) {
const ops: TimelinePatchOperation[] = []
let delta = 0
for (const seg of doc.aiSegments.sort(byStart)) {
const clip = findClipByBoundId(doc, seg.id)
if (!clip || !seg.ttsAudioPath) continue
const real = await probeDuration(seg.ttsAudioPath)
const estimated = clip.duration
delta += real - estimated
ops.push({ op: 'updateClip', targetId: clip.id, value: { duration: real } })
// 后续 clip 的 start 补偿 delta......
}
return ops
}
五、"建议"与"绑定"的分离:推荐不等于落盘
recommendedAssetIds 和 boundClipIds 是两个独立字段,对应产品语义的分离:
- recommended:AI 说"这几段素材和口播稿语义匹配",UI 展示为可点选的素材卡片。
- bound :用户点击采纳后,才真正产生 Clip 并写入
boundClipIds。
这个分离让 AI 的"置信度"不直接污染用户数据------用户可以只采纳 3 个推荐中的 1 个,也可以手工换掉某个绑定。数据模型上,一个分段允许 recommendedAssetIds 非空而 boundClipIds 为空(纯建议态)、两者都非空(已采纳态),但不允许 bound 引用了不在 recommended 里的素材(如果产品要求"只能采纳推荐",那就在 superRefine 里加子集校验)。
评分侧,素材推荐通常融合多路信号:
ts
function scoreAsset(seg: AiSegment, asset: Asset, signals: Signals): number {
return (
0.45 * semanticSim(seg.scriptText, asset.caption) + // 语义相似度(embedding 余弦)
0.25 * visualMatch(seg.keywords, asset.tags) + // 视觉标签匹配
0.15 * temporalAffinity(seg.start, asset.usageBias) + // 时段偏好
0.15 * qualityScore(asset) // 清晰度/稳定性
)
}
权重是产品调参位,但评分只影响排序,不影响数据合法性------这个边界要守住。
六、小结
AI 分段引擎的工程本质是给不确定性画边界:
| 层 | 职责 | 确定性 |
|---|---|---|
| LLM | 产出语义分段(文字层) | 低,需校验重试 |
| 分段 Schema | 六字段外键模型 + superRefine 引用完整性 | 高 |
| 映射器 | 分段 → op 列表(纯函数、幂等) | 确定 |
| 补丁管线 | 校验、写盘、历史栈 | 确定 |
| 校准器 | TTS 实时时长回填 + 重排 | 确定 |
LLM 只在最上层做"理解",其余每一层都是传统软件工程。这个分层让"一键成片"具备三个传统编辑器才有的能力:可撤销 (每个分段采纳都是一个历史快照)、可增量编辑 (改口播稿只重算受影响的 Clip)、可解释(每个 Clip 能回溯到分段与推荐依据)。AI 剪辑产品的护城河不在模型,在这层确定性管线。