提示

返回博客列表

剪辑软件之间的时间线怎么交换:EDL、XML、FCPXML 与自动剪片

客户发来一份 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 只能从关键帧开始,精确剪切必须重编码)。

目录

一、为什么需要交换格式

剪辑软件之间没有通用的工程文件格式:

  • 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 秒的入点偏差,他们会在剪辑软件里微调)。

坑清单

  1. 自己解析 XML → 格式细节多,容易错。用 OTIO。
  2. 把丢帧时间码当非丢帧算 → 一小时差 3.6 秒。用 OTIO 或正确的换算函数。
  3. 时间码起点当成了经过时间 → 01:00:00:00 是起点不是"过了一小时"。
  4. 用 -c copy 期待精确到帧 → 只能从关键帧开始。要么吸附,要么重编码。
  5. 吸附关键帧后没校验时长 → 切出来的长度和时间线不一致。
  6. EDL 只支持剪切 → 如果客户的时间线有速度变化、缩放、多机位,EDL 会丢失这些信息。用 XML/AAF。
  7. FCPXML 和 FCP7 XML 格式完全不同 → 不要以为都是 XML 就能用同一套代码解析。
  8. 片段名称有特殊字符 → 输出文件名要 sanitize。
  9. 源文件和时间线对不上 → 时间线引用的是"原始素材",而我们拿到的是"转码后的文件"。确认引用关系。
  10. 多轨(V1/V2/音频轨)没处理 → 只取了视频轨,忽略了音频轨或者叠加轨。明确要哪个轨道。
  11. 转场(dissolve)没考虑 → 转场处两个片段有重叠,按剪切点切会重复或者丢失。
  12. 片段之间有空隙 → 时间线不是连续的,切出来的片段不连续。
  13. 生成的 EDL 格式不对(列宽/空格)→ 客户导入失败。让客户试导一次。
  14. 忘了带时间码 → 客户导入后时间码对不上。用 -timecode 写入。
  15. 没问客户用什么软件 → 不同软件支持的格式不同(Avid 对 EDL 要求严格)。先问。

最后说说这次的收获。

最大的教训是:看起来"简单"的数据交换,往往藏着行业历史的包袱。

丢帧时间码这种东西,是 1950 年代 NTSC 彩色兼容的历史遗留——它完全不符合直觉(为什么一分钟要跳过两帧?),但它至今仍在北美广播行业广泛使用。如果你不知道这段历史,就会像我一样踩坑。

这也解释了为什么"用成熟库"如此重要:OTIO 的作者知道所有这些历史包袱,他们把丢帧换算、格式差异、嵌套处理都做对了。我自己写的代码,就算逻辑正确,也会漏掉一堆边界情况。

所以现在的判断标准是:

  • 通用、有标准、有历史包袱的东西(时间码、容器格式、编码标准)→ 找成熟库;
  • 自己的业务逻辑(怎么命名、怎么并行、怎么校验)→ 自己写。

还有一点:交付时要说清楚精度。-c copy 切出来的片段有 ±3 秒的入点误差,这在我的场景里没问题,但如果客户是做"精确回批"(要跟原时间线一帧不差),就必须重编码。这个差异要在开工前说清楚,而不是交付后被发现。

所有的"差不多"都应该在事前量化成"差多少",然后让对方确认。 这是我在这个项目里学到最实用的一条。

想亲手试试?用 VidDown 一键解析下载

粘贴视频链接即可解析,多平台支持、网页端即用;下载桌面客户端解锁海外平台本地解析,开通会员更享不限次下载。

顶部