Toolkit-agnostic core for embedding libmpv in Rust applications: handle lifecycle, commands, properties, typed playback events, and render seams.
Extracted from two independent embedders after their engine layers converged on the same API without coordinating:
- a GTK4 app that renders mpv directly into a
GtkGLArea's FBO inside GTK's GL context (the GNOME Celluloid pattern), and - an iced widget that renders into its own side EGL context, then
reads back RGBA (SW path) or exports the FBO to a
wgpu::Texture(GPU path).
Those two render strategies are legitimately different — the shared part is everything underneath, and that's this crate.
A quirk belongs in mpv-engine if its consumer-facing interface
survives the upstream fix. Anything whose shape would change with a
toolkit's API belongs in that toolkit's adapter crate.
Owned here today:
LC_NUMERIC=Cforced beforempv_create— toolkits (GTK included) callsetlocale()from the environment; mpv's number parsing breaks under comma-decimal locales. Both parent projects carried this guard independently.- Render-context lifecycle: created only when a GL context is live, freed
strictly before the mpv handle, and freed with the GL context
current (
detach_render) — otherwise mpv's GL objects leak into whatever context is current (in GTK: whole-window artifacts after popping the player page). - Commands as pre-tokenized argument arrays (
mpv_command), never a joined command string — paths with spaces/quotes need no escaping. Pinned by a regression test (loadfile_handles_awkward_filenames) because the string-joined variant shipped a real bug. - Typed events:
pump_events()→Loaded/Ended{reason}(EOF vs stop, for playlist logic) /PlaybackRestart(seek finished — snap scrubbers here) /PropertyChanged/Failed{code, message}, with mpv's negative error codes expanded tompv_error_stringtext plus the numeric code (diagnostic strings — user-facing copy is the integrator's job). Errored end-of-file arrives asFailed, not silently. - The update callback's threading contract: fires on mpv's render thread
(
Sendrequired), plus once synchronously at registration; the crate has no main-loop opinion — bridging to GTK'sspawn_localor iced's subscriptions is the shell's job. The callback fixed at attach can be replaced afterwards (set_render_update_callback, mirroring the wakeup seam's replaceability) — for shells whose real closure can only capture state built after the engine is shared: attach with a placeholder, wrap the engine, then register the weak-capturing closure. The replacement's synchronous registration fire runs outside every engine lock — the same contract as the attach-time fire. load_paused()— pause set beforeloadfile, so demuxing doesn't start before the shell's window is mapped (the init-time variant of the same idea tends to hang).load_when_ready()— the full deferred-load policy on top of that: on a render-API engine with no context attached, theloadfileitself waits and the attach call issues it (an eager pre-attach load fails VO init and drops the video track); where no attach is coming, it degrades to a plainload. The full contract — supersede rules, pause interaction, failure surfacing — lives in the rustdoc, its one home. Every shell otherwise reimplements this queue — or worse, ships the eager-load race.- A software render backend (
attach_sw_render/render_sw, RGBA into a caller buffer, no GL) sharing the one attach slot with the GL backend — including the fix for mpv's"rgb0"output leaving the fourth byte undefined, which consumers treating the buffer as RGBA read as garbage alpha.attached_render()reports which backend is live (RenderKind), so shells routing between per-backend paths (GPU texture sampling vs. RGBA upload) don't track the attach outcome in state of their own. - An exported-frame backend on macOS, Linux and Windows (non-default
exportfeature):attach_exported_renderspawns an engine-owned render thread with a hidden GL context (CGL on macOS; surfaceless EGL over a DRM render node on Linux — no display server involved; WGL on an invisible window on Windows), mpv renders into exportable framebuffers there (IOSurface-backed / DMA-BUF-backed / shared-D3D11-texture-backed viaWGL_NV_DX_interop2), and the shell pullsExportedFramehandles (acquire_frame) to import into Metal / Vulkan / D3D / wgpu — the consumer-side answer to libmpv's GL-or-software render API, with no GL and no pixel copies in the shell. Zero-copy end to end on macOS and Linux; on Windows the engine pays one GPU-localCopyResourceper frame, for the reason in the next paragraph. Fully safe API (the GL-currency contract never crosses it), newest-wins frame pool, live resizing (set_export_size), and BGRA8 row-0-at-top output pinned by an orientation test on each platform (on Linux: single-planeAR24/XR24, linear modifier, so Vulkan import needs no modifier negotiation; on Windows: aDXGI_FORMAT_B8G8R8A8_UNORMshared NT handle with deliberately no keyed mutex, so any importer can open it — carried on a second texture, becausewglDXRegisterObjectNVrefuses NT-handle-shared resources whileOpenSharedHandleaccepts nothing else, so the publish barrier copies between them). Thewgpufeature (impliesexport) addsExportedFrame::into_wgpu_texture: the frame wrapped as awgpu::Textureon the shell's device — Metal on macOS (IOSurface wrap, pool buffer returned via the hal drop callback), Vulkan on Linux (wgpu-hal's own dmabuf import; the device opts in withFeatures::VULKAN_EXTERNAL_MEMORY_DMA_BUF), or DX12 on Windows (ID3D12Device::OpenSharedHandle, pool buffer returned when D3D12 destroys the opened resource); on Linux the pool buffer is retired outright instead, since wgpu's imported reference owns the frame's memory from there and that hal path offers no drop-callback seam — with the frame's memory kept out of mpv's hands until wgpu's own GPU-completion tracking releases the texture: no manual hold-until-completion discipline on any platform. Verified by tests that read the texture back through wgpu and compare byte-for-byte with the exported buffer. - An event wakeup seam (
set_wakeup_callback) so shells get a push signal when events queue instead of pollingpump_eventson a timer — the only timely path for audio-only use or failures while paused. - Property access through crate-owned types (
PropertyValue, the sealedPropertyGet) — the binding's conversion traits never appear in this crate's public API, so a binding major bump can't become a semver break here.
Explicitly not here: widgets, main-loop integration, toolkit types, and git-pinned dependencies (crates.io forbids them, and they'd re-couple this crate to exactly the churn it exists to contain).
mpv-engine (core: this crate)
├── GTK adapter widget/controls over core (GTK GLArea → render_gl)
└── iced adapter widget + Subscription + shader pipeline; side-context render
Shells attach a render target with attach_gl_render(get_proc_address, options, on_update) (an unsafe fn — it carries the GL-context-currency
contract the type system can't express: the target context must be
current at attach, every render_gl/render_update, and detach/drop)
and draw via render_gl(fbo, w, h, flip_y) from their paint handler —
or, GL-free and fully safe, attach_sw_render(on_update) and pull RGBA
bytes with render_sw(w, h, &mut buf). GlRenderOptions fixes the
shell's render-loop discipline at attach: the default blocks render_gl
until the frame's target display time (right for a GTK paint handler),
while a shell rendering on a compositor thread (iced prepare) sets
block_for_target_time: false and paces frames itself (or sets
video-timing-offset=0); advanced_control is there too, obligating
render_update() after every update callback. Events are drained with
pump_events() on the shell's own cadence, with set_wakeup_callback
as the push signal for shells that don't poll. Headless/audio
use (Engine::headless(), vo=null) needs neither — that's also how the
test suite runs without a display.
On Linux, EGL 1.5's eglGetProcAddress resolves everything mpv asks for.
Avoid libepoxy on glvnd builds: it doesn't export core GL symbols as
dlsym-able functions and mpv reports MPV_ERROR_UNSUPPORTED. On
Windows the loader must be both halves: wglGetProcAddress (with a
current context, and treating the 1/2/3/-1 returns as failures) for
extensions, falling back to GetProcAddress on opengl32.dll for the
GL 1.1 core it exports — a one-half loader fails mpv on half its symbol
requests. The export backend's own loader does this; a shell wiring
attach_gl_render on Windows owes it the same.
The export and wgpu features have shipped on all three platforms —
IOSurface → Metal on macOS; DMA-BUF (GBM + surfaceless EGL, linear
layout, glFinish publish barrier) → Vulkan via wgpu-hal's dmabuf
import on Linux; shared D3D11 texture (WGL + WGL_NV_DX_interop2,
glFinish plus interop unlock as the publish barrier) → DX12 via
OpenSharedHandle on Windows — see above. The rows below remain.
| feature | contents | churn it contains |
|---|---|---|
egl |
side EGL context + FBO render + RGBA readback (GL-accelerated; the pure-software path is already in core as render_sw) |
none — stable APIs |
export (Linux, sync tier) |
explicit sync-fd export (EGL_ANDROID_native_fence_sync) so the glFinish publish barrier can relax into a consumer-side semaphore wait; optional DRM-modifier negotiation as an ExportOptions knob |
the permanent workarounds — e.g. wgpu-hal never enables VK_KHR_external_semaphore_fd, so the strict-loader vkGetSemaphoreFdKHR path lives here indefinitely |
export (Windows, sync tier) |
a shared D3D11 fence (ID3D11Device5::CreateFence + CreateSharedHandle) so the glFinish publish barrier can relax into a consumer-side wait, and adapter pinning so the shell can force the hidden context onto its own GPU |
keyed mutexes are the obvious alternative and are not usable — wgpu-hal's DX12 importer never acquires one — so the fence path lives here indefinitely |
The default feature set never grows unstable dependencies: a consumer on
core + egl alone is structurally isolated from all of it.
The iced-side upstream watch (wgpu-30 bump via cryoglyph, damage tracking, foreign-texture widget, HDR surface config) stays in the iced adapter crate — those items fail the interface-survival rule.
cargo test runs headless against real libmpv (vo=null); media inputs
are generated with ffmpeg. Both are probed at runtime — missing tooling
skips tests rather than failing them. System deps: the libmpv dev
package to build, ffmpeg to generate test inputs; on Linux the export
feature additionally links libEGL and libgbm (their dev packages at
build time), and on Windows opengl32, d3d11, dxgi, user32 and gdi32
(all in the Windows SDK the MSVC toolchain already needs). With
--features export, the exported-backend tests
render through a real hidden GL context (CGL on macOS, EGL on a DRM
render node on Linux, WGL on Windows) and read pixels back through the
exported buffer (IOSurface lock / dmabuf mmap / D3D11 staging copy) — a
session without GPU access (no WindowServer, no readable
/dev/dri/renderD*, no OpenGL ICD or no WGL_NV_DX_interop2) skips
them the same way.
--features wgpu additionally round-trips frames through a real wgpu
device (Metal / Vulkan with VULKAN_EXTERNAL_MEMORY_DMA_BUF / DX12 on
the engine's own adapter), skipping when no capable adapter exists.
There's also a runnable end-to-end demo of the exported-frame → wgpu
path — a minimal winit player with no GL and no shaders in the app
(one copy_texture_to_texture is the whole compositor):
cargo run --features wgpu --example winit_player -- path/to/video.mkvThings a change must not weaken (each is a lesson paid for in one of the parent projects):
- Commands are argument arrays, never a joined command string — no
matter how convenient
mpv_command_stringlooks for a one-off. The war story: an earlier command layer joined args into one command line, and a filename with spaces producedMPV_ERROR_INVALID_PARAMETERin production.loadfile_handles_awkward_filenamespins the behavior, so it also guards binding swaps and future major bumps. - Drop order: render context strictly before the mpv handle. Since
rsmpv 0.2 the ordering is structural — the render context co-owns the
core via
Arc<Mpv>, so there is noDropimpl to maintain. Don't reintroduce a path where a render context can outlive its core. detach_renderwith the GL context current is the real teardown path;Dropis only a fallback that can't guarantee it. Don't soften that contract in docs or code.- Default features stay boring: no toolkit deps, no git deps (crates.io forbids them anyway), no unstable upstreams. Churny interop goes behind the non-default features in the roadmap table.
MIT OR Apache-2.0 — and via the clean-room rsmpv bindings (MIT OR
Apache-2.0 over an ISC sys crate), that now covers the crate's entire
Rust dependency tree.
Binaries still carry libmpv's terms: it is GPLv2+ in default builds
(LGPLv2.1+ only when built with -Dgpl=false), and FFmpeg's build flags
affect the combined license too. Before distributing anything built on
this crate, see LICENSING.md for the layer-by-layer
picture and the common gotchas.