返回博客

Tauri 桌面 App 自包含:内置静态 ffmpeg + 把 OpenCV 算法移植到 Rust

做了一个 Tauri 2 视频处理桌面 App,在开发机上运行完美:ffmpeg 转码、ffprobe 探测、Python sidecar 做镜头切分,一切正常。打包成 .dmg 发给测试同学,依赖自检全红——对方机器没装 ffmpeg,也没有 Python/opencv 环境,核心功能全废。

要做到"双击即用",就必须把运行时依赖塞进包。这条路上有两个特别反直觉的坑,踩完才明白为什么。

现象

  • 生产 .dmg 打开后,依赖自检显示 ffmpeg/ffprobe「未就绪」;
  • resolve_bin("ffmpeg") 返回裸名,走系统 PATH,用户机没有就直接报错;
  • Python sidecar(opencv/scenedetect)在没有 .venv 的机器上完全不可用;
  • 开发机一切正常,所以这个问题在发布前很难被发现。

根因

Tauri 不会自动把系统里的 ffmpeg 带进包——它只管 bundle 你显式声明的资源。Command::new("ffmpeg") 本质上是在运行时找系统 PATH,打包后完全没有这个可执行文件。Python sidecar 依赖虚拟环境,而 cv2/numpy 原生 wheel 极难做可重定位,塞进 bundle 会带来几十上百 MB 的体积,且重定位极脆。

坑1:不要用 homebrew ffmpeg 做打包源

homebrew 的 ffmpeg 动态链接 /opt/homebrew/... 下一大堆 dylib(libavcodec、openssl、x264……)。用 install_name_tool 逐一重定位这几十个库,脆且费时,且每次 brew upgrade 都会破坏。正确做法是从静态构建拿二进制。

验证:otool -L ffmpeg | grep -v "/System/\|/usr/lib/" 应只剩二进制本身,无任何 /opt 路径。

坑2:ffmpeg 的 scene/scdet 滤镜无法替代感知型切镜检测器

scene/scdet硬切检测器,只看相邻帧的瞬时差异。对 AI 生成的连续 morph、慢变长镜头,逐帧差异都极小,不触发阈值,整段被当成一镜。自研的 OpenCV HSV 直方图 + Bhattacharyya 累积漂移检测器的灵魂正在于此——不能用 ffmpeg 内置滤镜简单替代。

正解

1. 拿静态自包含 ffmpeg

https://ffmpeg.martin-riedl.de/ 下载 macOS arm64 静态构建(~63 MB/个)。静态构建含 libx264(CPU 软编回退)+ h264_videotoolbox(GPU 硬编)+ scdet/select 滤镜,且无外部动态库依赖。

注意:该构建是 GPL(含 x264/x265),对外分发需遵守 GPL。二进制不要进 git(127 MB),放 src-tauri/binaries/,写个 fetch-ffmpeg.sh 在 CI 拉取。

2. 作为 resource 打包,运行时优先用包内

tauri.conf.json

"bundle": {
  "resources": {
    "binaries/ffmpeg":  "bin/ffmpeg",
    "binaries/ffprobe": "bin/ffprobe"
  }
}

Rust 侧用 OnceLock 缓存路径,resolve_bin 优先走包内,dev 模式回退系统 PATH:

static BUNDLED_BIN_DIR: OnceLock<PathBuf> = OnceLock::new();
 
fn resolve_bin(name: &str) -> String {
    if let Some(dir) = BUNDLED_BIN_DIR.get() {
        let p = dir.join(name);
        if p.exists() { return p.to_string_lossy().into(); }
    }
    name.to_string() // 回退系统 PATH(dev 模式)
}

setup() 中优先读 resource_dir()/bin,找不到则回退 CARGO_MANIFEST_DIR/binaries(dev)。

3. 预签名 bundled 二进制(公证前置条件)

Tauri 递归签 resources 里的二进制。必须在 tauri build 之前签好源文件(签名随文件复制进 bundle):

codesign --force --timestamp --options runtime \
  --sign "$APPLE_SIGNING_IDENTITY" src-tauri/binaries/ffmpeg
codesign --force --timestamp --options runtime \
  --sign "$APPLE_SIGNING_IDENTITY" src-tauri/binaries/ffprobe

三个 flag 缺一不可:--timestamp(公证要求)、--options runtime(hardened runtime)、--force(覆盖已有签名)。验证:codesign -dvvv .../Resources/bin/ffmpeg 应显示 flags=0x10000(runtime) + Timestamp + 你的 TeamID。

正解:静态 ffmpeg + 预签名 resource

静态构建无外部依赖,otool -L 干净;tauri build 前预签好源文件,签名随 copy 进 bundle;codesign --verify --deep --strict 通过,spctl --assess 显示 Notarized Developer ID

4. 用 ffmpeg rawvideo 管道把 OpenCV 算法移植到 Rust

不需要 cv2,让 bundled ffmpeg 解码+缩放+采样,吐 rgb24 裸流到 stdout:

ffmpeg -v error -i <path> -vf "fps=3,scale=160:90" -f rawvideo -pix_fmt rgb24 -

Rust 用 read_exact(&mut buf)(buf = W×H×3 字节/帧)按帧对齐读取,UnexpectedEof 干净收尾。

OpenCV 的 Bhattacharyya 公式必须 1:1 复刻,才能让已调好的阈值无缝迁移:

  • BGR→HSV(8-bit,H∈[0,180])V=max(r,g,b); ch=V-min; S=V==0?0:ch*255/V,H 按 OpenCV 定义做整数除法后 /2 存为 H180
  • 直方图 bins[16,16,8],之后做 L2 归一化(对齐 cv2.normalize 默认 NORM_L2)
  • HISTCMP_BHATTACHARYYAd = sqrt(1 - Σ√(h1ᵢ·h2ᵢ) / √(H̄1·H̄2·N²))

必做交叉验证:写 Python 脚本,对随机帧分别用 cv2 真实管线和「Rust 公式的 Python 重写版」算 Bhattacharyya,diff < 0.01(实测可达 < 0.003)即说明阈值可无缝迁移。

正解:删 Python sidecar,Rust 内实现感知切镜

ffmpeg rawvideo 管道 → Rust 按帧读取 → HSV 直方图 + Bhattacharyya 累积漂移。cv2 交叉验证 max diff 0.003,阈值无需重调。最终 dmg 无 Python/scenedetect/opencv 依赖,体积可控。

一句话外卖

桌面 App 打包自包含的核心是「消灭隐式 PATH 依赖」:静态二进制进 resource、预签名、resolve_bin 优先走包内;Python sidecar 的算法迁移到 Rust 比塞整个 Python runtime 可靠得多,但必须对着 cv2 做数值交叉验证才能保证阈值可迁移。