我有一段四十二分钟的白噪音视频。声音是下雨的现场录音,画面是一张静态背景图;只有片头十几秒的字幕会动,后面基本就是同一张图。

目标很简单:片头字幕要保留,后面的背景没必要每秒存 16 张;音频也不能为了省空间转成低码率 AAC。 于是做了一个小工具,用来找出字幕在哪一段,再分别处理字幕和背景。

先看原文件

动手前先确认原视频到底有多大、帧率和音频是什么。

$ soundscape-video-compressor analysis ~/录音/2026-05-21雨声/公园雨夜.mov
0.000s ~ 2509.562s: 16.000 FPS, 14.983 Mbps
TOTAL: 40153 frames, 16.000 FPS, 14.983 Mbps
AUDIO: pcm_s24le/s32, 48000 Hz, stereo, 24-bit, 2.304 Mbps, 722.754 MB, 13.321% of file

画面全程 16 FPS,使用 HEVC(H.265)视频编码,平均码率接近 15 Mbps,一共有 40,153 帧。音频是 48kHz、双声道、24-bit PCM,也就是没有压缩过的采样数据,单独就有 722.754 MB;只是原视频更大,所以它在文件里只占 13%。

这也说明不能只把 HEVC 的质量参数调低。那样编码器还是会反复编码同一张背景图,音频的体积也完全没动。

只找片头字幕

我并不需要识别“这是不是雨”或“这是不是字幕”。 这张背景图不动,只要找出哪些帧明显不同于背景噪点,就能大致框出字幕出现和消失的时间。

检测时,视频先缩到默认宽度 320,转成灰度,再以默认 4 FPS 读取。相邻两帧逐像素比较:亮度差超过 3 的像素算作变化;用平均亮度差和变化像素比例计算一个分数。

阈值是动态的,先取所有分数的中位数,再计算 MAD(中位数绝对偏差,可以理解成背景噪点通常有多大),最后用下面的值判断是否发生变化:

中位数 + sensitivity × max(MAD, 0.02)

默认 sensitivity 为 6。这样背景里的压缩噪点不容易被误当成字幕。检测出的短片段会跳过,相隔很近的片段会合并,也可以在边界前后补一点时间。

工具会缓存分段信息,用源文件的 SHA-256 内容摘要和检测参数作为缓存键,避免下次又从头扫一遍。范围本身也会参与缓存键,不能把“只检查片头”的结果误用于整段视频。

在 macOS 上,首次检测优先用 VideoToolbox 硬解,也就是调用苹果提供的硬件编解码接口;失败才回退软件解码。对 4K HEVC 素材,扫描时间从约 45.5s 降到约 20.5s

这套检测方式也能标记其他静态背景视频中的变化片段,但那只是顺带的能力;这次它只负责找片头字幕。

两种输出方式

adaptive :字幕保留原始帧率(保留动画),静态背景只留每秒一帧。 它用 VFR(可变帧率)写入文件,减少重复的背景帧:动态部分使用原始帧率,静态部分可以降低到 1 帧。因为文件体积更小,它适合本地存储和播放。

fixed 是为 VFR 上传兼容准备的。 例如 把动态帧率视频上传到 B 站,平台会把整段时间线统一改成平均帧率。为了不把这个过程交给平台,先在本地输出指定的固定帧率视频。它和 adaptive 只降低静态背景帧率不同。 为了让所有时间都能按同一低帧率输出,它丢掉了字幕淡入淡出等变化:变化段会使用前一个稳定画面,而不是保留字幕渐变。例如 10s ~ 12s 是字幕出现的过程,这个模式会继续显示字幕完整出现之前的静态背景。去掉这些动态过程后,整段视频就可以用更低的固定帧率输出。

视频用 Apple VideoToolbox 编码为 10-bit HEVC。文件写入 hvc1 标记以便 Apple 设备识别,保留 HLG、PQ 或 SDR 的色彩信息:前两者是 HDR 高动态范围。再加上 +faststart,把播放索引放到文件开头,让网页和手机不用等文件完全下载就能开始播放。

这次使用的命令

这次我已经知道字幕只在片头,所以只让 FFmpeg 解码前 15 秒;之后一律当作静态背景。这样可以减少分析的时间。

字幕保留原始帧率,后面的静态背景降到 1 帧;视频使用 VideoToolbox H.265,音频转成 ALAC(Apple Lossless,无损音频编码):

soundscape-video-compressor convert \
  --fps-mode adaptive \
  --input ~/录音/2026-05-21雨声/公园雨夜.mov \
  --output ~/录音/2026-05-21雨声/公园雨夜-alac.mov \
  --dynamic-ranges '00:00,00:15' \
  --static-fps 1 \
  --quality 60 \
  --audio-codec alac

--quality 控制单帧的编码质量。

I 帧 是能独立解码的一整张画面,播放器从中间开始播放时,通常要先找到它。P 帧主要记录它与前面画面的差异;B 帧同时参考前后画面,通常还能压得更小。即使背景只有 1 帧,保留下来的单帧仍可能是较大的 I 帧,所以 1 帧不等于码率会接近零。

最终结果

转换后的分析结果:

# 片头字幕开始出现,保留原始 16 FPS。
0.000s ~ 1.000s: 16.000 FPS, 13.383 Mbps
# 字幕暂时静止,背景按 1 FPS 保留。
1.000s ~ 2.000s: 1.000 FPS, 0.010 Mbps
# 字幕变化边界落在帧之间,因此这一小段的实际展示间隔为 1.333 FPS。
2.000s ~ 2.750s: 1.333 FPS, 0.061 Mbps
# 字幕消失
2.750s ~ 5.000s: 16.000 FPS, 8.526 Mbps

5.000s ~ 5.750s: 1.333 FPS, 0.014 Mbps
# 字幕出现
5.750s ~ 7.000s: 16.000 FPS, 9.977 Mbps
# 字幕静止
7.000s ~ 8.000s: 1.000 FPS, 0.010 Mbps
8.000s ~ 8.750s: 1.333 FPS, 0.024 Mbps
# 字幕消失
8.750s ~ 11.000s: 16.000 FPS, 8.229 Mbps

# 片头字幕结束;之后是静态背景。
11.000s ~ 2509.000s: 1.000 FPS, 0.516 Mbps

# 为保证总时长,结尾和尾帧完整保留,不是新的字幕变化。
2509.000s ~ 2509.250s: 4.000 FPS, 3.032 Mbps
2509.250s ~ 2509.562s: 16.000 FPS, 0.096 Mbps

# 视频汇总
TOTAL: 2613 frames, 1.041 FPS, 0.539 Mbps

# 音频
AUDIO: alac/s32p, 48000 Hz, stereo, 24-bit, 1.428 Mbps, 447.802 MB, 72.559% of file
项目 原始文件 转换后
总大小 5.426 GB 617.155 MB
视频大小 4.700 GB 169.184 MB
音频 PCM,722.754 MB ALAC,447.802 MB
音频占比 13.321% 72.559%
视频平均码率 14.983 Mbps 0.539 Mbps
总帧数 40,153 2,613

总大小减少约 88.6% 片头字幕仍以 16 FPS 播放,之后绝大多数时间为 1 FPS。1.333 FPS 的短区间来自字幕边界和原始时间戳;结尾的 4 FPS16 FPS 则用于保证总时长和尾帧完整,并不是新的字幕变化。

音频从 722.754 MB 变为 447.802 MB,少了约 38%。它在新文件里占到 72%,不是音频变大,而是视频缩得更快。alac/s32p 里的 s32p 只是 FFmpeg 解码时使用的格式;原始精度看 bits_per_raw_sample=24。我把 ALAC 和原始 PCM 都解码成 s24le,再比较 SHA-256,结果相同,说明没有改变任何一个 24-bit 样本。

这不是通用的视频压缩器,也不是内容理解工具。它解决的是一个很具体的问题:保留片头字幕,把四十多分钟重复的背景图降到 1 FPS,同时保住原始录音。 这里最有效的不是把视频压得更糊,而是别重复保存本来就没有变化的背景帧。

开源

Github: weaming/soundscape-video-compressor

其中移除重复帧功能 mpdecimate 在 FFmpeg 里有,闹半天是已有开源实现的功能 😅。

示例 Fish 封装:

# dedup mode removes near-duplicate frames and writes VFR output.
function ffmpeg-to-h265 -a input quality dedup
    if test (count $argv) -lt 1; or test (count $argv) -gt 3
        echo 'Usage: ffmpeg-to-h265 INPUT [QUALITY 1-100] [DEDUP]' >&2
        return 2
    end

    if test -z "$input"
        echo 'INPUT is required' >&2
        return 2
    end

    if not test -f "$input"
        echo "Input video does not exist: $input" >&2
        return 1
    end

    if test -z "$quality"
        set quality 70
    end

    if not string match -rq '^[0-9]+$' -- "$quality"
        echo 'QUALITY must be an integer from 1 to 100' >&2
        return 2
    end

    if test "$quality" -lt 1; or test "$quality" -gt 100
        echo 'QUALITY must be an integer from 1 to 100' >&2
        return 2
    end

    set -l output (string replace -r -- '\.[^./]+$' '-h265.mov' "$input")
    if test "$output" = "$input"
        set output "$input-h265.mov"
    end

    set -l videoArguments
    if test -n "$dedup"
        set videoArguments -vf 'mpdecimate=max=240:hi=200:lo=100:frac=0.02' -fps_mode:v vfr
    end

    ffmpeg -i "$input" \
        -map 0:v:0 -map '0:a?' \
        $videoArguments \
        -c:v hevc_videotoolbox -q:v "$quality" \
        -tag:v hvc1 -c:a copy \
        -movflags +faststart "$output"
end

这样这个开源实现只有 analysis 子命令和去动画降帧实现比较有参考价值了 🚀。