客户发来一份 Premiere 的时间线,说:"按这个切出里面的每一段,导出成单独的文件。"
我打开一看是个 XML,里面有一堆
<clipitem>和<start><end>标签,看着不难。于是我写了个脚本按这些数字切了 40 段——结果每段都差了几秒。查下来是丢帧时间码(Drop-Frame Timecode)的问题:素材是 29.97fps,时间线里的时间码是
01:00:12;04这种带分号的丢帧格式,而我按"帧数 ÷ 30"换算了。一个小时下来差了 3.6 秒。这次之后我把时间线交换这件事搞明白了。这篇写完整过程,以及最后用的解决方案(OpenTimelineIO)。
TL;DR:时间线交换格式有 EDL(CMX3600,最老最简单)、FCP7 XML(Premiere/FCP7 通用)、FCPXML(Final Cut Pro X,格式完全不同)、AAF(专业复杂)。不要自己解析——用 OpenTimelineIO(OTIO) 这个开源库,一份代码读所有格式。三个必须注意的点:丢帧时间码(29.97fps 的
HH:MM:SS;FF带分号,直接按 30fps 算会累积误差)、时间码起点(广播常用01:00:00:00而不是00:00:00:00)、ffmpeg 剪切的精确性(-c copy只能从关键帧开始,精确剪切必须重编码)。
目录
- 一、为什么需要交换格式
- 二、四种格式各是什么
- 三、时间码:一切的基础
- 四、丢帧时间码:我踩的那个坑
- 五、用 ffmpeg 按剪辑点切
- 六、别自己解析 XML:用 OpenTimelineIO
- 七、自动剪片流水线
- 八、反向:我们生成时间线给客户
- 九、实测与坑清单
一、为什么需要交换格式
剪辑软件之间没有通用的工程文件格式:
- Premiere 用
.prproj(二进制); - Final Cut Pro 7 用
.fcp(XML); - Final Cut Pro X 用
.fcpxml/ 库; - DaVinci Resolve 有自己的工程格式;
- Avid 有自己的 bin。
这些格式互不兼容,所以行业里发明了"交换格式":把时间线导出成一种大家都认识的中性格式,对方再导入。
典型场景:
- 剪辑师用 Premiere 剪好 → 导出 XML → 调色软件(DaVinci)导入 → 调色 → 回批;
- 剪辑完成 → 导出 EDL → 交给音频后期做混音;
- 客户给我们时间线 → 我们按剪辑点批量切出片段(本篇的场景)。
二、四种格式各是什么
| 格式 | 全称/标准 | 内容 | 现状 |
|---|---|---|---|
| EDL | CMX3600(源自 CMX 编辑系统) | 只有剪切点(入点/出点/源/时间码) | 老但通用,音频后期常用 |
| FCP7 XML | Final Cut Pro 7 的 XML 交换格式 | 时间线、片段、效果、转场 | 事实标准,Premiere 也支持 |
| FCPXML | Final Cut Pro X 的格式 | 同上但结构完全不同 | FCPX 生态 |
| AAF | Advanced Authoring Format | 最完整(含媒体引用、效果参数) | 专业后期,复杂 |
| OTIO | OpenTimelineIO(Pixar 开源) | 不是交换格式,是"读所有格式"的库 | 推荐 |
EDL 长什么样(CMX3600):
TITLE: MY_PROJECT
FCM: NON-DROP FRAME
001 AX V C 00:00:10:00 00:00:25:00 01:00:05:00 01:00:20:00
002 AX V C 00:00:30:00 00:00:45:00 01:00:20:00 01:00:35:00
003 AX V C 00:01:00:00 00:01:12:00 01:00:35:00 01:00:47:00
每行的字段(简化):
事件号 轨道 类型 转场 源入点 源出点 记录入点 记录出点
001 AX V C 00:00:10:00 00:00:25:00 01:00:05:00 01:00:20:00
- 源时间码(source/record):素材里的位置;
- 记录时间码:时间线上的位置;
C表示切(cut),D/W表示转场(dissolve/wipe)。
EDL 的优点:简单、人可读、所有软件都支持。
EDL 的缺点:只能表达剪切和简单转场——没有缩放、没有调色、没有多机位、没有速度变化。所以复杂项目用 XML/AAF。
三、时间码:一切的基础
SMPTE 时间码格式:HH:MM:SS:FF(时:分:秒:帧)
01:23:45:12 → 1 小时 23 分 45 秒 第 12 帧
帧号范围取决于帧率:
| 帧率 | FF 范围 | 说明 |
|---|---|---|
| 24 | 0~23 | |
| 25 | 0~24 | PAL |
| 30 | 0~29 | |
| 29.97 | 0~29 | NTSC,有丢帧和非丢帧两种 |
| 50/60 | 0~49/59 |
时间码起点:广播领域常用 01:00:00:00 作为节目起点(而不是 00:00:00:00),这是历史习惯(磁带预留)。所以看到 01:00:00:00 不要以为时间线已经过了一小时。
用 ffprobe 看文件的时间码起点:
ffprobe -v error -show_entries stream_tags=timecode -show_entries format_tags=timecode \
-of default=noprint_wrappers=1 input.mov
# timecode=01:00:00:00
ffmpeg 也可以写入时间码:
ffmpeg -i in.mp4 -c copy -timecode 01:00:00:00 out.mov
四、丢帧时间码:我踩的那个坑
背景:NTSC 的"30fps"实际是 29.97fps(30000/1001),因为彩色信号兼容的需要。
如果用"非丢帧"(NDF)时间码——也就是每秒老老实实数 30 帧:
真实经过 1 小时 = 107892 帧(29.97 × 3600)
时间码显示 = 01:00:00:00 → 按 30fps 算是 108000 帧
差了 108 帧 = 3.6 秒。也就是说,播了一小时之后,时间码比真实时间快了 3.6 秒。
丢帧时间码(DF)的解决办法:每分钟跳过 2 个帧号(除了每 10 分钟的那些分钟)。这样时间码就能和真实时间对上。
写法上的区别:
非丢帧(NDF): 01:00:00:00 ← 冒号
丢帧(DF): 01:00:00;00 ← 分号(最后的帧分隔符)
这个分号是关键标记。看到 ; 就知道是丢帧时间码。
换算(丢帧时间码 → 真实秒数):
def df_timecode_to_seconds(tc: str, fps: float = 30000 / 1001) -> float:
"""丢帧时间码转秒。tc 形如 '01:02:03;04' 或 '01:02:03:04'。"""
tc = tc.replace(';', ':')
hh, mm, ss, ff = [int(x) for x in tc.split(':')]
drop = ';' in tc or tc.count(':') == 3 and ';' in tc
# 丢帧:每分钟跳过 2 帧(不跳每 10 分钟的整数分钟)
total_minutes = hh * 60 + mm
frame_number = (
(total_minutes * 60 + ss) * 30 + ff
- 2 * (total_minutes - total_minutes // 10) if drop
else (hh * 3600 + mm * 60 + ss) * 30 + ff
)
return frame_number / (30.0 if not drop else 30000 / 1001)
(注意上面这段代码里 drop 的判断写得不够严谨,生产环境建议直接用 OTIO 的 RationalTime,见第六节。)
实际影响(我踩的那个):
- 时间线里第 20 个片段的时间码是
01:24:16;12; - 我按 30fps 算 → 5056 秒;
- 正确值(29.97 + 丢帧补偿)→ 5052.4 秒;
- 差了 3.6 秒,切出来的片段开头和结尾都偏了。
教训:看到 29.97fps 的素材,第一件事是确认时间码是 DF 还是 NDF。EDL 头部有 FCM: DROP FRAME / FCM: NON-DROP FRAME 的标记;XML 里通常有 <frame_rate> 和 NDF/DF 的属性。
五、用 ffmpeg 按剪辑点切
精确剪切 vs 快速剪切
# 方式 1:-ss 在 -i 之前(先 seek 再解码),快
ffmpeg -ss 100 -i input.mp4 -t 30 -c:v libx264 -crf 18 out.mp4
# 方式 2:-ss 在 -i 之后(解码到位置再输出),慢但"更精确"
ffmpeg -i input.mp4 -ss 100 -t 30 -c:v libx264 -crf 18 out.mp4
现代 ffmpeg 里两者都是精确的(方式 1 也会解码到目标点,只是跳过了前面的解码)。区别在于:
- 方式 1:快(seek 后从关键帧开始解码到目标点);
- 方式 2:慢(从头解码)。
所以日常用方式 1 就行。
关键帧的坑(-c copy)
# 不重新编码:只能从关键帧开始
ffmpeg -ss 100 -i input.mp4 -t 30 -c copy out.mp4
用 -c copy 时,ffmpeg 必须从关键帧开始。如果你的 -ss 落在非关键帧上:
- ffmpeg 会从前面的关键帧开始(所以输出比你要的长一点,开头多出 0~2 秒);
- 或者输出开头花屏(取决于版本和参数)。
所以:
| 需求 | 做法 |
|---|---|
| 精确到帧,且画面不能花 | 重新编码(-c:v libx264,慢) |
| 快速、接受 ±2 秒误差 | -c copy(快,但要对齐关键帧) |
| 快速且尽量精确 | -c copy + 把剪切点吸附到关键帧 |
吸附到关键帧(推荐的做法,兼顾速度和精度):
import subprocess
def keyframes(path):
out = subprocess.run(
['ffprobe', '-v', 'error', '-select_streams', 'v:0', '-skip_frame', 'nokey',
'-show_entries', 'frame=pts_time', '-of', 'csv=p=0', path],
capture_output=True, text=True, check=True).stdout
return [float(x) for x in out.split() if x]
def snap(t: float, keys: list) -> float:
"""把时间点吸附到不晚于它的最近关键帧。"""
prev = 0.0
for k in keys:
if k > t:
break
prev = k
return prev
对批量切片段(40 段),吸附之后误差最多是一个 GOP(2~4 秒),对大多数交付是可以接受的,而且速度是重新编码的几十倍。
如果客户要求精确到帧(比如用于二次剪辑),那就必须重编码——要在报价和工期里体现。
六、别自己解析 XML:用 OpenTimelineIO
OTIO(OpenTimelineIO) 是 Pixar 开源的库,能读写 EDL / FCP7 XML / FCPXML / AAF(部分)/ 自己的 otio 格式。
安装:
pip install OpenTimelineIO
读取时间线并遍历片段:
import opentimelineio as otio
def read_timeline(path):
tl = otio.adapters.read_from_file(path)
print(f'时间线名: {tl.name}, 时长: {tl.duration()}')
clips = []
for clip in tl.find_clips():
# 时间线上的范围
rng = clip.range_in_parent() # 可能为 None(未链接的片段)
if rng is None:
continue
src = clip.source_range
clips.append({
'name': clip.name,
'start': rng.start_time.to_seconds(),
'duration': rng.duration.to_seconds(),
'source_start': src.start_time.to_seconds() if src else None,
'source_duration': src.duration.to_seconds() if src else None,
})
return clips
if __name__ == '__main__':
for c in read_timeline('timeline.xml'):
print(f"{c['name']}: 时间线 {c['start']:.2f}s 起,长 {c['duration']:.2f}s")
它帮你处理了:
- 丢帧/非丢帧时间码的换算(
RationalTime内部用有理数表示,不会累积误差); - 不同格式的差异(EDL 的时间码 vs XML 的帧数);
- 嵌套(时间线里套时间线)、转场、速度变化。
格式转换(比如把 XML 转成 EDL 给音频后期):
import opentimelineio as otio
tl = otio.adapters.read_from_file('timeline.xml')
otio.adapters.write_to_file(tl, 'timeline.edl', adapter_name='cmx_3600')
这一步能省掉大量手工工作——客户给什么格式我们都能读,要什么格式我们都能写。
七、自动剪片流水线
把上面的组合起来:
import subprocess
from concurrent.futures import ProcessPoolExecutor
from pathlib import Path
def cut_segment(args):
src, out, start, dur, mode = args
if mode == 'copy':
cmd = ['ffmpeg', '-v', 'error', '-y', '-ss', f'{start:.3f}', '-i', src,
'-t', f'{dur:.3f}', '-c', 'copy', '-avoid_negative_ts', 'make_zero', out]
else:
cmd = ['ffmpeg', '-v', 'error', '-y', '-ss', f'{start:.3f}', '-i', src,
'-t', f'{dur:.3f}', '-c:v', 'libx264', '-crf', '18', '-preset', 'slow',
'-c:a', 'aac', '-b:a', '192k', '-movflags', '+faststart', out]
subprocess.run(cmd, check=True)
return out
def auto_cut(source_video, timeline_path, out_dir, mode='copy', workers=4):
clips = read_timeline(timeline_path)
keys = keyframes(source_video) if mode == 'copy' else None
jobs = []
for i, c in enumerate(clips, 1):
start = snap(c['start'], keys) if mode == 'copy' else c['start']
name = f'{i:03d}_{sanitize(c["name"])}.mp4'
jobs.append((source_video, str(Path(out_dir) / name), start, c['duration'], mode))
Path(out_dir).mkdir(parents=True, exist_ok=True)
with ProcessPoolExecutor(max_workers=workers) as ex:
outs = list(ex.map(cut_segment, jobs))
# 校验:每个片段的实际时长和预期是否一致
report = []
for (_, out, start, dur, _), o in zip(jobs, outs):
real = probe_duration(o)
report.append({'file': Path(o).name, 'expect': dur, 'real': real,
'diff': abs(real - dur)})
return report
校验这一步很重要:切完之后要确认每个片段的时长跟时间线一致(-c copy 模式下可能有偏差)。偏差超过阈值(比如 2 秒)就标记出来。
八、反向:我们生成时间线给客户
有时候反过来——我们切好的片段要交回给客户进剪辑软件。这时候可以生成一个简单的 EDL:
def frames_to_tc(frames: int, fps: float = 25.0, drop: bool = False) -> str:
"""帧号转时间码。"""
ff = int(frames % round(fps))
total_s = int(frames // round(fps))
ss = total_s % 60
mm = (total_s // 60) % 60
hh = total_s // 3600
sep = ';' if drop else ':'
return f'{hh:02d}:{mm:02d}:{ss:02d}{sep}{ff:02d}'
def write_edl(clips, out_path, title='AUTO_CUT', fps=25.0, start_tc='01:00:00:00'):
"""clips: [{'reel': 'AX', 'src_in': 帧, 'src_out': 帧, 'rec_in': 帧, 'rec_out': 帧}]"""
lines = [f'TITLE: {title}', 'FCM: NON-DROP FRAME', '']
for i, c in enumerate(clips, 1):
lines.append(
f"{i:03d} {c['reel']:<7} V C "
f"{frames_to_tc(c['src_in'], fps)} {frames_to_tc(c['src_out'], fps)} "
f"{frames_to_tc(c['rec_in'], fps)} {frames_to_tc(c['rec_out'], fps)}"
)
open(out_path, 'w').write('\n'.join(lines) + '\n')
注意 EDL 的格式要求很严格(列位置、空格数量),有些软件对格式错误零容忍。生成之后最好让客户试着导入一次。
或者用 OTIO 写:
import opentimelineio as otio
tl = otio.schema.Timeline(name='AUTO_CUT')
track = otio.schema.Track(name='V1', kind=otio.schema.TrackKind.Video)
tl.tracks.append(track)
# 添加 clip...
otio.adapters.write_to_file(tl, 'out.edl', adapter_name='cmx_3600')
九、实测与坑清单
实测(40 个片段,源是 25fps 1080p ProRes):
| 模式 | 总耗时 | 时长误差 | 画质 |
|---|---|---|---|
-c copy + 关键帧吸附 |
1 分 12 秒 | 0~3.2 秒(GOP 长度) | 无损(原始码流) |
| 重新编码(CRF 18) | 22 分钟 | < 0.05 秒 | 轻微损失 |
-c copy 不吸附 |
1 分 05 秒 | 0~3.2 秒 + 开头可能花屏 | 部分损坏 |
我们最后用:-c copy + 关键帧吸附(快、无损),并把误差说明写进交付文档(客户是做二次剪辑的,能接受 ±3 秒的入点偏差,他们会在剪辑软件里微调)。
坑清单
- 自己解析 XML → 格式细节多,容易错。用 OTIO。
- 把丢帧时间码当非丢帧算 → 一小时差 3.6 秒。用 OTIO 或正确的换算函数。
- 时间码起点当成了经过时间 →
01:00:00:00是起点不是"过了一小时"。 - 用
-c copy期待精确到帧 → 只能从关键帧开始。要么吸附,要么重编码。 - 吸附关键帧后没校验时长 → 切出来的长度和时间线不一致。
- EDL 只支持剪切 → 如果客户的时间线有速度变化、缩放、多机位,EDL 会丢失这些信息。用 XML/AAF。
- FCPXML 和 FCP7 XML 格式完全不同 → 不要以为都是 XML 就能用同一套代码解析。
- 片段名称有特殊字符 → 输出文件名要 sanitize。
- 源文件和时间线对不上 → 时间线引用的是"原始素材",而我们拿到的是"转码后的文件"。确认引用关系。
- 多轨(V1/V2/音频轨)没处理 → 只取了视频轨,忽略了音频轨或者叠加轨。明确要哪个轨道。
- 转场(dissolve)没考虑 → 转场处两个片段有重叠,按剪切点切会重复或者丢失。
- 片段之间有空隙 → 时间线不是连续的,切出来的片段不连续。
- 生成的 EDL 格式不对(列宽/空格)→ 客户导入失败。让客户试导一次。
- 忘了带时间码 → 客户导入后时间码对不上。用
-timecode写入。 - 没问客户用什么软件 → 不同软件支持的格式不同(Avid 对 EDL 要求严格)。先问。
最后说说这次的收获。
最大的教训是:看起来"简单"的数据交换,往往藏着行业历史的包袱。
丢帧时间码这种东西,是 1950 年代 NTSC 彩色兼容的历史遗留——它完全不符合直觉(为什么一分钟要跳过两帧?),但它至今仍在北美广播行业广泛使用。如果你不知道这段历史,就会像我一样踩坑。
这也解释了为什么"用成熟库"如此重要:OTIO 的作者知道所有这些历史包袱,他们把丢帧换算、格式差异、嵌套处理都做对了。我自己写的代码,就算逻辑正确,也会漏掉一堆边界情况。
所以现在的判断标准是:
- 通用、有标准、有历史包袱的东西(时间码、容器格式、编码标准)→ 找成熟库;
- 自己的业务逻辑(怎么命名、怎么并行、怎么校验)→ 自己写。
还有一点:交付时要说清楚精度。-c copy 切出来的片段有 ±3 秒的入点误差,这在我的场景里没问题,但如果客户是做"精确回批"(要跟原时间线一帧不差),就必须重编码。这个差异要在开工前说清楚,而不是交付后被发现。
所有的"差不多"都应该在事前量化成"差多少",然后让对方确认。 这是我在这个项目里学到最实用的一条。