ffsubsync:基于 Python 与 ffmpeg 的字幕自动同步工具

Automagically synchronize subtitles with video.

Branch9Tags44
This repository is empty

FFsubsync

CI 状态 支持乌克兰 使用 mypy 检查 代码风格:black 许可证:MIT Python 版本 文档状态 PyPI 版本

与语言无关的字幕与视频自动同步工具,可将字幕对齐到视频中正确的起始位置。

变为此: 变为此:

在浏览器中试用(无需安装)

现在您可以完全在浏览器中同步字幕 — 无需 Python、无需 ffmpeg,也无需安装任何东西。您的文件永远不会离开您的设备(不会上传任何内容):

👉 https://smacke.github.io/ffsubsync

浏览器版本可根据已正确同步的参考字幕视频/音频文件进行同步 — 音频通过 ffmpeg.wasm 在浏览器中解码(大文件会延迟读取,绝不会上传)。对于批量或脚本化使用,请安装下面的命令行工具。

助力开发

请考虑支持乌克兰,而非直接向本项目捐赠。话虽如此,应部分人士的请求,现在你可以通过页面顶部的 Github Sponsors 按钮,或下方的 Paypal 捐赠按钮来帮我支付咖啡费用:

捐赠

安装

首先,请确保已安装 ffmpeg。在 MacOS 系统上,安装命令如下:

brew install ffmpeg

(Windows 用户注意:确保 ffmpeg 已添加到你的路径中,并且可以从命令行引用!)

接下来,获取该软件包(兼容 Python >= 3.6):

pip install ffsubsync

如果你想冒险尝试,可以通过以下方式获取最新版本:

pip install git+https://github.com/smacke/ffsubsync@latest

使用方法

ffssubsyncffsubsync 均可作为入口点:

ffs video.mp4 -i unsynchronized.srt -o synchronized.srt

有时你可能会遇到这样的情况:你有一个同步正常但语言不熟悉的 srt 文件,同时还有一个母语的 srt 文件但同步存在问题。这种情况下,你可以直接将那个同步正常的 srt 文件用作同步参考,而不是以视频作为参考:

ffsubsync reference.srt -i unsynchronized.srt -o synchronized.srt

ffsubsync 会根据文件扩展名来决定是对音频执行语音活动检测,还是直接从 srt 文件中提取语音。

如果省略 -iffsubsync 会自动检测与参考文件同名且位于同一目录下的输入字幕,因此你只需运行:

ffs video.mp4

此功能会从参考文件的目录中选取 video.srtvideo.en.srt 这类文件,并为每个文件在其旁边生成同步后的结果,保存为 <name>.synced.srt(例如 video.synced.srt),原始文件保持不变。之前已同步生成的 *.synced.srt 文件会被跳过,因此重复运行是安全的。添加 --overwrite-input 选项可以直接覆盖检测到的文件。当通过标准输入管道传入字幕时,会跳过自动检测。

参考文件也可以是远程 URL,而非本地文件。任何 ffmpeg 可直接读取的内容都能作为视频/音频参考,远程字幕文件同样可以作为参考:

ffs "https://example.com/video.mp4" -i unsynchronized.srt -o synchronized.srt
ffs "https://example.com/reference.srt" -i unsynchronized.srt -o synchronized.srt

支持的协议包括 http(s)://rtmp://rtsp://ftp://

处理过程会通过网络流式传输参考文件,因此依赖于网络连接的稳定性;对于大型文件或不稳定的源,建议先下载文件,以确保处理更可靠。

同级字幕自动检测(即上述不带 -i 的形式)仅支持本地文件,对于远程参考文件会跳过该检测。

为加快长参考文件的处理速度,可使用 --max-duration-seconds N 仅处理前 N 秒的内容(从 --start-seconds 开始计算)。这对于远程参考文件尤其有用,因为一旦达到指定时长,ffmpeg 会停止读取(从而停止下载):

ffs "https://example.com/video.mp4" -i unsynchronized.srt -o synchronized.srt --max-duration-seconds 600

在连接不稳定的情况下,--extract-audio-first 可能更为可靠:它不会在整个语音检测过程中一直保持网络流打开,而是先将远程音频轨道复制到本地临时文件(不重新编码),然后基于该文件进行检测。对于本地参考文件,此选项将被忽略,并且它可与 --max-duration-seconds 组合使用:

ffs "https://example.com/video.mp4" -i unsynchronized.srt -o synchronized.srt --extract-audio-first

对于较长的参考内容,当 --max-duration-seconds 可能会遗漏仅在后期出现的不同步问题时,--multi-segment-sync 会改为在整个参考内容中分散采样多个短片段,并仅对这些片段运行语音检测:

ffs "https://example.com/video.mp4" -i unsynchronized.srt -o synchronized.srt --multi-segment-sync

仅提取采样音频(对于远程引用,还会进行下载),但由于每个片段在时间轴上都保持其真实位置,因此常用的帧率比率和偏移量搜索不受影响——所以帧率不匹配问题仍能被检测并纠正。可通过 --segment-count N(默认值为 8)、--skip-intro-outro(跳过前 30 秒/后 60 秒,这些部分通常没有对话)和 --parallel-workers N(重叠片段下载,默认值为 4)进行调整。此功能仅适用于视频/音频引用。

Docker

预构建镜像已发布到 GitHub Container Registry。使用以下命令拉取最新版本:

docker pull ghcr.io/smacke/ffsubsync:latest

通过将包含视频和字幕的目录挂载到 /video 来运行它:

docker run --rm -v "$PWD":/video ghcr.io/smacke/ffsubsync:latest \
  video.mp4 -i unsynchronized.srt -o synchronized.srt

您也可以自行构建镜像。多阶段 Dockerfile 默认从当前工作目录进行安装:

docker build -t ffsubsync .

若要从 PyPI 安装特定版本,请设置 FFSUBSYNC_VERSION

docker build -t ffsubsync --build-arg FFSUBSYNC_VERSION=0.4.31 .

作为库使用

可通过 ffsubsync.run 以编程方式调用 ffsubsync,该函数接受一个 argparse.Namespace(使用 make_parser 创建)。若要在您自己的用户界面中显示进度,请传入 progress_handler 回调函数;在解码参考音频期间,会反复调用此回调函数,并传入一个 ProgressInfo,用于描述提取进度。

import ffsubsync
from ffsubsync.ffsubsync import make_parser

def on_progress(info: ffsubsync.ProgressInfo) -> None:
    # info.processed_seconds / info.total_seconds (total may be None);
    # info.fraction is a 0.0-1.0 ratio (None if the total is unknown).
    if info.fraction is not None:
        print(f"{info.fraction:.0%}")

args = make_parser().parse_args(["ref.mkv", "-i", "in.srt", "-o", "out.srt"])
result = ffsubsync.run(args, progress_handler=on_progress)

该处理器仅在处理视频/音频参考路径时被调用(这是同步过程中的主要开销)。它引发的异常会被记录并忽略,因此即使处理器存在缺陷,也绝不会导致同步过程中断。

字符编码

实际应用中的字幕文件往往采用各种 legacy 编码(如西里尔文常用 Windows-1251,中文常用 GBK/Big5,还有 Latin-1、Shift-JIS、带 BOM 的 UTF-16 等等)。与其他字幕同步工具相比,ffsubsync 在稳健处理这些编码方面表现出色,并且这一过程是自动完成的:--encoding 参数默认值为 infer,它会将输入视为原始字节并自动检测编码。在底层,它会按顺序尝试最多三种检测器,并采用第一个给出结果的检测器——首先是 cchardet,然后是 charset_normalizer,最后是 chardet——解码时使用 errors="replace",因此即使猜测略有偏差,也会以一种平缓的方式降级处理,而不是直接崩溃。BOM 会被自动处理(检测器会直接读取原始字节)。

如果自动检测猜测错误,可以通过例如 --encoding windows-1251 来强制指定编码。输出默认采用 UTF-8 编码;若要保留输入的编码,可以传递 --output-encoding same,或者显式指定任何编解码器。当参考文件本身是字幕文件时,--reference-encoding 参数(默认同样为 infer)用于控制其编码。

有一个跨版本的注意事项值得了解:最快且通常最准确的检测器 cchardet 由维护的 faust-cchardet 分支提供(该分支替代了未维护的原始 cchardet,并以 cchardet 模块名进行安装)。它仅被声明为 Python < 3.13 的依赖项(在 requirements.txt 中表示为 faust-cchardet;python_version<'3.13')。在 Python 3.13+ 上,不会安装该依赖,因此 import cchardet 会静默失败,检测将回退到纯 Python 实现的 charset_normalizerchardet。在实际使用中,这通常难以区分,但在处理模糊的 legacy 编码时,猜测结果可能会有所不同——这种情况下,只需显式传递 --encoding 参数,或者在安装了 C 语言检测器的 Python 3.12 或更早版本下运行即可。完整说明请参见 编码文档

同步问题

如果同步失败,可尝试以下解决方法:

  • 假设视频和字幕帧率相同,可通过传递 --no-fix-framerate 进行同步;
  • 尝试传递 --gss 以使用 黄金分割搜索 找到视频和字幕帧率之间的最佳比例(默认情况下,仅评估少数常见比例);
  • 如果字幕不同步超过 60 秒,可尝试将 --max-offset-seconds 的值设置为大于默认的 60(实际中这种情况不太可能,但仍有可能发生)。
  • 如果字幕开始时同步,但在播放过程中逐渐偏移——例如删减了广告时段、插入或删除了某个场景(如“导演剪辑版”),或者将两张光盘的内容合并为一个文件——那么单一的全局偏移无法同时修正前后两部分。此时可尝试 --split-penalty,该选项启用 alass 风格的分段对齐,允许偏移量在时间轴上变化,仅在确实能改善对齐的位置引入分段。不带值传递此标志将使用合理的默认值,或传递一个数字(重叠秒数;通常为 4–20)来设置每次分段的代价——值越低,分段越积极;值越高,越接近单一偏移。完整说明请参见 高级选项文档
  • 尝试 --vad=auditok,因为在音频质量较低的情况下,auditok 有时比 WebRTC 的 VAD 效果更好。Auditok 并非专门检测人声,而是检测所有音频;在合适的 VAD 能正常工作时,此特性可能导致同步效果不佳,但在某些情况下可能有效。
  • 尝试 --vad=fused,它结合了 WebRTC 和神经网络 silero VAD,在嘈杂音频上可能更稳健。可调整策略:--vad=fused:intersection(保守模式——仅在两者都检测到语音时才认定为语音)、--vad=fused:union(激进模式——任一检测到语音即认定为语音),或 --vad=fused:weighted(默认值)。这些选项需要可选的 silero 依赖项,而 silero 本身需要 PyTorch;可通过 pip install ffsubsync[torch](或直接 pip install torch)安装两者。torch 并非默认随 ffsubsync 一同安装。
  • 对于没有可借鉴字幕的视频,可尝试使用 whisper.cpp 转录音频,并将转录文本作为参考:--whisper-weights ~/whisper.cpp/models/ggml-base.en.bin。这需要 ffmpeg(>= 8.0)且在编译时启用了 --enable-whisperffsubsync 会展开路径中的 ~,自动推断语言(对于 *.en.bin 模型为英语,其他情况自动检测;可通过 --language 覆盖),并在视频已包含内嵌字幕时发出警告。额外的 whisper 过滤器选项可通过 --whisper-args 传递(例如 --whisper-args queue=12)。

批量同步时,错误的同步可能比不同步更糟。传递 --skip-sync-on-low-quality 会在对齐质量不佳时(如相关性分数过低 [--min-score,默认 0.0] 或偏移量过大 [--quality-max-offset-seconds,默认 30])保持字幕不变。还有 --max-framerate-deviation 检查(默认 0.1,允许 ffsubsync 进行的所有帧率校正);仅当确定帧率不应改变时,才收紧此限制。

如果同步仍然失败,可考虑尝试以下类似工具:

  • sc0ty/subsync:进行语音转文本并查找匹配的词语素
  • kaegi/alass:基于 Rust 的字幕同步器,采用高级动态规划算法
  • tympanix/subsync:基于神经网络的方法,在进行语音检测时直接优化对齐
  • oseiskar/autosubsync:使用定制的频谱图和逻辑回归进行语音检测
  • pums974/srtsync:与 ffsubsync 方法类似(WebRTC 的 VAD + FFT 以最大化信号互相关)

速度

ffsubsync 通常在 20 到 30 秒内完成,具体取决于视频的长度。最耗时的步骤实际上是原始音频的提取。如果您已经有一个同步正确的“参考”srt 文件(这种情况下可以跳过音频提取),ffsubsync 通常在不到一秒钟内就能运行完成。

工作原理

同步算法分为 3 个步骤:

  1. 将视频文件的音频流和字幕都离散为 10 毫秒的时间窗口。
  2. 对于每个 10 毫秒的窗口,判断该窗口是否包含语音。对于字幕来说,这很简单(我们只需判断每个时间窗口内是否有字幕“处于显示状态”);对于音频流,则使用现成的语音活动检测器(VAD),例如 webrtc 中内置的检测器。
  3. 现在我们得到了两个二进制字符串:一个对应字幕,一个对应视频。尝试通过将 0 与 0 匹配、1 与 1 匹配来对齐这些字符串。我们对这些对齐方式进行评分,计算公式为:(视频中 1 与字幕中 1 匹配的数量)减去(视频中 1 与字幕中 0 匹配的数量)。

步骤 3 中得分最高的对齐方式决定了如何对字幕进行时间偏移,以使其与视频正确同步。由于二进制字符串相当长(对于超过一小时的视频,可达数百万位),使用朴素的 O(n²) 策略来对所有对齐方式进行评分是不可接受的。相反,我们利用“对所有对齐方式进行评分”这一过程本质上是卷积运算的特性,并通过快速傅里叶变换(FFT)来实现,从而将复杂度降至 O(n log n)。

局限性

在大多数情况下,视频和字幕之间的不一致性出现在视频中存在的开头或结尾片段在字幕中不存在,或者反之。例如,当字幕中的电视剧剧情回顾部分在视频中被删减时,就会出现这种情况。ffsubsync 在这些情况下通常表现良好,根据我的经验,这涵盖了超过 95% 的使用场景。而在开头和结尾片段之外的中断和分割——例如中间删减的广告时段、插入或删除的场景,或者将两张光盘的内容合并为一个文件——则更为棘手,因为没有单一的全局偏移量能够同时修复两侧的同步问题。实验性的 --split-penalty 模式可以处理其中许多情况;请参见上文的同步问题

未来工作

除了常规的稳定性和可用性改进外,还有一项工作旨在增强 alass 风格的 --split-penalty 模式,该模式用于处理视频中间存在但字幕中不存在的分割/中断(或反之)。目前,该模式在许多情况下表现良好,但仍处于实验阶段。更多详情请参见 #10

历史

本项目的开发始于 2019 年的 HackIllinois 黑客马拉松,并在此次活动中获得了荣誉提名(排名前五,不包括获得企业特定奖项的项目)。

致谢

本项目的实现离不开以下库的支持:

  • ffmpeg 及其 Python 封装 ffmpeg-python,用于从视频中提取原始音频
  • webrtc 中的语音活动检测(VAD)及其 Python 封装 py-webrtcvad,用于语音检测
  • srt,用于处理 SRT 文件
  • numpy,以及间接依赖的 FFTPACK,它们为基于 FFT 的快速字幕对齐评分算法(或字幕与视频的对齐评分算法)提供了支持
  • 其他优秀的 Python 库,如 argparserichtqdm,虽然与核心功能无关,但为开发者和用户带来了更出色的使用体验。

许可证

本项目的代码采用 MIT 许可证