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

在 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 + 竖版
开工前默念三遍:
- 大文件 MKV 别指望
<video> - spike 能播 ≠ 产品能播
- 别在 WKWebView 上挖洞——壁纸不适合当预览
给后来者的三句话
- 元数据能读,不代表能播。
ffprobe和预览引擎是两件事。 - 难的不是 mpv,是 WKWebView。 离屏 → Canvas 是当前最省心的和解方案。
- 预览和导出要解耦。 用户导出要原片画质;预览要的是「快、稳、能 seek」。
延伸阅读
- LIBMPV-TAURI-GUIDE.md — 工程向完整指南(线程模型、检查清单、文件索引)
- BUG-FIXING.md §5 — 每次翻车的实验室记录
- PLAY_HUGE_FILE.md — 大文件播放长文(命令层、硬解矩阵、缩略图方案)
如果你也在做桌面视频工具,希望这篇能帮你少踩一两个坑。代码能抄,坑别重复——至少,别在 WKWebView 上挖洞。