mpv-engine
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.
What lives here (and the rule that decides it)
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 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).
Consumers
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.
Roadmap (planned non-default features)
| 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 |
ExportedFrame: OPAQUE_FD/DMA-BUF + GL semaphore export (ash only) |
the permanent workarounds — e.g. wgpu-hal never enables VK_KHR_external_semaphore_fd, so the strict-loader vkGetSemaphoreFdKHR path lives here indefinitely |
wgpu |
import ExportedFrame → wgpu::Texture via wgpu-hal |
wgpu-major lockstep, isolated behind a non-default feature; releases track wgpu majors (the egui-wgpu pattern). When iced reaches wgpu 30, add_wait_semaphore replaces the empty-submit semaphore hack and create_texture_from_hal's initial_state lands — internals change, the feature's API doesn't |
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.
Testing
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.
Invariants for contributors
Things 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.
License
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.