Blog

在 Tauri 里播 4GB MKV:我如何把 libmpv 塞进 WebView

Cover Image for 在 Tauri 里播 4GB MKV:我如何把 libmpv 塞进 WebView
ZD
ZD

在 Tauri 里播 4GB MKV:我如何把 libmpv 塞进 WebView

一篇偏轻松的复盘,但会塞代码——非科班能看懂故事,科班能抄走片段。
完整参数与检查清单见 LIBMPV-TAURI-GUIDE.md


30 秒读懂(给赶时间的人)

问题 答案
为啥 <video> 不行? 浏览器不认 MKV / HEVC,元数据能读 ≠ 能播
为啥 remux 代理也不行? 4GB 全片转码太慢,seek 还黑屏,容易死循环
为啥 spike 能播、嵌进去不行? WKWebView 和 mpv 原生层「抢座位」,叠层方案全翻车
最后怎么解决的? mpv 在屏幕外画好 → 把像素发给前端 Canvas
代价? 预览约 720p / 15fps,够剪片,不够当监视器

起因:元数据能读,画面出不来

我在做 clip-editor,一个 Tauri + React 的小视频工具。导入流程很顺利——ffprobe 秒回分辨率、时长、编码格式。用户拖进来一个 4.32GB、2160p、HEVC、MKV 的《绝命律师》,侧边栏信息整整齐齐。

然后预览区永远停在:「加载视频中…」

时间显示 0:00 / 0:00。没有报错,没有崩溃,就是——没有画面。

旧路径大概长这样(简化):

// 曾经的 VideoPlayer —— 看起来没问题,实际上在等一个永远不会来的事件
<video
  ref={videoRef}
  src={videoUrl}           // asset:// 代理 MP4,或者原文件
  onLoadedMetadata={() => setReady(true)}
  onError={() => regenerateProxy()}  // 失败后重试…再失败…再重试…
/>

ffprobe<video> 活在两个宇宙:前者只读容器头,后者要真的解码出像素。MKV?HEVC?E-AC3?WebView:「听说过,下次一定。」


第一直觉:转个代理呗

工程师的经典 reflex:播不了就 remux 成 MP4 嘛。

于是我搞了 preview.rs:检测不支持的格式 → 后台 ffmpeg 全片 remux → 缓存 → 用代理文件喂给 <video>

// 曾经的思路(已删除)—— 把「预览」变成「后台转码任务」
// needsPreviewProxy(meta) → generate_preview_proxy(path) → 等几分钟 → 祈祷 WebView 能播

然后更魔幻的事情发生了:

  • 4GB 文件 remux 进度条卡在 11%,或者从 0% 重新开始
  • 偶尔代理好了,拖到中间 黑屏
  • 播放失败自动重试 → 删缓存 → 再 remux → 死循环

元数据永远是对的,预览永远在折腾。用户只想剪个片,我在后台给他跑了一次小型转码农场。

教训:预览引擎和导出引擎不该绑在同一条 ffmpeg 管线上。导出可以继续 CLI;预览得换专业选手。


第二直觉:上 libmpv

IINA 怎么做,我就怎么做。loadfile 原路径,硬解,seek,逐帧——行业标准答案。

先做个 spike,独立窗口,不碰 Tauri UI:

cd src-tauri
cargo run --bin mpv_spike -- --auto "/path/to/Better.Call.Saul....mkv"

spike 里 mpv 的配置很朴素——有窗口,所以 vo=gpu 就行:

// mpv_spike / config.rs —— headless=false, embedded=false
init.set_property("vo", "gpu")?;      // 直接画到 NSWindow
init.set_property("hwdec", "auto-safe")?;
init.command("loadfile", &[path, "replace"])?;

成了。 Saul 坐在办公室里,画面清晰,声音正常。团队士气 +100。


第三直觉:既然 spike 能播,嵌进去不就完了?

呵呵。

把 mpv 塞进 Tauri,相当于告诉一个已经坐满人的电影院:「麻烦在中间加一排座位。」

macOS 上 Tauri 的 UI 住在 WKWebView 里。mpv 的常规姿势是 wid + vo=gpu,把画面直接画进某个 NSView。我试了:

方案 关键代码 / 配置 结果
wid 绑 WKWebView wid = webview.ns_view() 没画面,或弹出独立 mpv 窗
全窗透明挖洞 transparent: true + CSS 透明 UI 透出桌面壁纸
OpenGL 叠在 WebView 上 NSWindowOrderingMode::Above 有声音,画面全黑
专用 NSView + wid vo=gpu + wid=host_ptr 彩虹圈,app 卡死

最有挫败感的是「有声音无画面」——耳朵知道在播,进度条知道在走,眼睛不知道。像听广播剧版《绝命律师》。

独立 spike:  NSWindow → wid → vo=gpu     ✅
Tauri 内嵌:  NSWindow → WKWebView → ???  ❌❌❌

独立 spike 成功 ≠ 内嵌成功。 这个等式我是用一周时间换来的。


顿悟:别跟 WKWebView 抢 layer

绕了一圈才想明白:不要在 WebView 上面或里面「开天窗」。让 mpv 在看不见的地方画,画完把像素交给 Web 层。

架构一张图

┌──────────────────────────────────────────────────────────┐
│  React(WKWebView 里,照常写 UI)                          │
│                                                          │
│   <canvas ref={frameCanvas} />  ← 视频画面               │
│   <canvas ref={cropCanvas} />   ← 裁切框(叠在上面)        │
│   <TrimTimeline />              ← 时间轴、入出点           │
└────────────────────────▲─────────────────────────────────┘
                         │  Tauri Event: mpv:frame
                         │  { width, height, data: "<base64 RGBA>" }
┌────────────────────────┴─────────────────────────────────┐
│  Rust 主线程                                                │
│   隐藏 NSOpenGLView → FBO → glReadPixels → base64(后台编码) │
└────────────────────────▲─────────────────────────────────┘
                         │  libmpv Render API
┌────────────────────────┴─────────────────────────────────┐
│  libmpv(后台线程 poll / event)                            │
│   loadfile(原路径) · hwdec=auto-safe · hr-seek=yes        │
└──────────────────────────────────────────────────────────┘

裁切框、时间轴、按钮——照旧 HTML/CSS,叠在 canvas 上。各过各的桥,谁也不抢 layer。


能抄走的代码(精华版)

下面是从我仓库里摘出来的「最小可读片段」。不是完整项目,但够你理解数据怎么流。

1. mpv 配置:嵌入模式必须 vo=libmpv

// src-tauri/src/mpv/config.rs
if embedded {
    init.set_property("vo", "libmpv")?;       // 配合 Render API,不是 gpu
    init.set_property("force-window", "no")?; // 不要弹窗
}
init.set_property("hwdec", "auto-safe")?;     // macOS → VideoToolbox
init.set_property("hr-seek", "yes")?;         // 剪辑器要精确 seek
init.set_property("cache", "yes")?;
init.set_property("demuxer-max-bytes", "500MiB")?; // 大文件:流式读,不全量载入

人话:告诉 mpv「你别自己找窗口了,画到我这儿指定的离屏画布上」。

2. 初始化顺序:错一步就黑屏或卡死

attach_host()              // 主线程:创建离屏 OpenGL
    ↓
set_host_bounds(w, h)      // 主线程:按预览区尺寸建 FBO
    ↓
MpvController::new_embedded()  // 后台建 mpv,主线程 install render context
    ↓
load_file("/path/to/huge.mkv") // 直接原文件,不 remux
    ↓
request_redraw()           // 有帧更新时触发,且必须非阻塞

四条红线(踩过才知道疼):

// ❌ 在 render context 装好之前同步 draw → "render context not installed"
// ❌ 在 update_callback 里阻塞等待主线程画完 → UI 死锁
// ❌ mpv_shutdown 里异步 detach → 下次 attach 竞态,永远「加载视频中…」
// ✅ shutdown 必须同步拆掉 GL / FBO,再允许下一次 attach

3. 后端:画一帧,发给前端

// src-tauri/src/mpv/embed/macos.rs(节选)
ctx.render::<()>(fbo, w, h, true)?;
gl::ReadPixels(0, 0, w, h, gl::RGBA, gl::UNSIGNED_BYTE, rgba.as_mut_ptr());

// 帧率节流 + 分辨率上限,不然 IPC 把 app 打爆
const MIN_FRAME_INTERVAL_MS: u64 = 66;  // ~15fps
const MAX_RENDER_W: i32 = 1280;
const MAX_RENDER_H: i32 = 720;

// base64 放后台线程,别堵主线程
thread::spawn(move || {
    let data = STANDARD.encode(&rgba);
    app.emit("mpv:frame", json!({ "width": w, "height": h, "data": data }));
});

为啥要限 720p / 15fps? 算笔账就懂:

分辨率 一帧 RGBA 大小 若 60fps
1280×720 ~3.7 MB ~220 MB/s
3840×2160 ~33 MB ~2 GB/s 💀

4K 全分辨率走 IPC 是在跟物理定律吵架。预览够用就行。

4. 前端:收帧,画到 Canvas

// src/hooks/useMpvFrame.ts(节选)
listen<MpvFrameEvent>("mpv:frame", (event) => {
  const { width, height, data } = event.payload;
  const rgba = decodeBase64Rgba(data);

  // 先写到离屏 canvas(帧的原始分辨率)
  srcCtx.putImageData(image, 0, 0);

  // 再缩放到屏幕上 letterbox 后的显示区域
  const dw = canvas.clientWidth;
  const dh = canvas.clientHeight;
  ctx.drawImage(src, 0, 0, dw, dh);
});

人话:后端发来的像素尺寸 ≠ 你屏幕上看到的尺寸,一定要 drawImage 缩放,别假设 1:1。

5. 前端:播放控制就是 invoke + 听事件

// src/hooks/useMpvPlayer.ts(用法示意)
await invoke("mpv_load_file", { path: filePath });
await invoke("mpv_seek", { positionSecs: 12.5 });
await invoke("mpv_frame_step", { forward: true });

listen("mpv:time-pos", (e) => setCurrentTime(e.payload));
listen("mpv:file-loaded", () => setReady(true));
listen("mpv:error", (e) => setError(e.payload.message));

没有魔法。Rust 里是 mpv 命令,前端是 Tauri 的 invoke/event 桥。

6. 竖版不压扁:bounds 要同步「画面块」,不是「黑边容器」

预览区外层通常是 16:9 黑底,竖版视频在中间 letterbox。我算出了实际画面矩形:

// src/components/VideoPlayer.tsx(节选)
// 根据视频比例,在 16:9 容器里算 letterbox
if (videoAspect > containerAspect) {
  renderW = cw;
  renderH = cw / videoAspect;
} else {
  renderH = ch;
  renderW = ch * videoAspect;
}

// bounds 同步的是 previewRef(画面块),不是 containerRef(整块黑底)
useMpvPreviewBounds(previewRef, enabled, boundsKey);

踩坑实录:一度把 16:9 容器的宽高发给 mpv,竖版人物被拉成面条,调试了半个下午。

7. resize 不卡死:防抖是尊严

// src/hooks/useMpvPreviewBounds.ts(节选)
const syncDebounced = () => {
  rafId = requestAnimationFrame(() => {
    debounceId = setTimeout(pushBounds, 180); // 拖窗口时别洪水式 invoke
  });
};
new ResizeObserver(syncDebounced).observe(el);

后端对应 schedule_host_bounds:合并 pending 尺寸,异步跑主线程,别在 resize 风暴里同步 glReadPixels


一些听起来很蠢、但确实踩过的坑

「spike 过了就算完工」

没有。请在 带 UI 的 app 里验收:

pnpm tauri dev
# 然后导入 4GB MKV,拖窗口,切竖版片源,别只看 mpv_spike

裁切 1:1 能拉成巨幕

clampRect 分别限制宽和高,比例约束就悄悄碎了:

// ❌ 独立 clamp —— 1:1 会变成 1078×1609 这种怪比例
const w = Math.min(rect.w, 1);
const h = Math.min(rect.h, 1);

// ✅ 先锁像素宽高比,再塞进画面
let h = w / normRatio;  // normRatio 把「1:1」换算进归一化坐标

竖版 1080×1920 选 1:1,最大裁切框是 1078×1078,不是几乎全屏。


现在的体验(macOS)

拿验收素材说人话:

操作 以前(WebView) 现在(libmpv + Canvas)
打开 4.32GB HEVC MKV 分钟级 remux 或永远加载 几秒出画面
拖时间轴 经常黑屏 正常 seek
拖窗口边缘 不彩虹圈了(防抖 + 异步 bounds)
竖版视频 比例正常
裁切 1:1 真是正方形

诚实代价:预览约 720p / 15fps,够剪片,别拿它当调色监视器。要上 4K 实时,得 IOSurface 共享纹理——那是下一章了。


最小抄作业清单

如果你明天就要在 Tauri 项目里接 libmpv,按这个顺序来:

# 1. 环境
brew install mpv

# 2. 依赖(Cargo.toml)
# libmpv2, gl, base64, objc2-app-kit (NSOpenGLView)

# 3. build.rs 链上 libmpv
# cargo:rustc-link-search=/opt/homebrew/lib
# cargo:rustc-link-lib=dylib=mpv

# 4. 冒烟(不碰 Tauri UI)
cargo run --bin mpv_spike -- --auto "/path/to/test.mkv"

# 5. 嵌入:vo=libmpv + 离屏 FBO + mpv:frame + 前端 Canvas
# 6. 验收:在 pnpm tauri dev 里测大文件 + resize + 竖版

开工前默念三遍

  1. 大文件 MKV 别指望 <video>
  2. spike 能播 ≠ 产品能播
  3. 别在 WKWebView 上挖洞——壁纸不适合当预览

给后来者的三句话

  1. 元数据能读,不代表能播。 ffprobe 和预览引擎是两件事。
  2. 难的不是 mpv,是 WKWebView。 离屏 → Canvas 是当前最省心的和解方案。
  3. 预览和导出要解耦。 用户导出要原片画质;预览要的是「快、稳、能 seek」。

延伸阅读

如果你也在做桌面视频工具,希望这篇能帮你少踩一两个坑。代码能抄,坑别重复——至少,别在 WKWebView 上挖洞。