mpv-engine 0.1.1

Toolkit-agnostic libmpv embedding core: handle lifecycle, commands, typed events, and render seams.
Documentation

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=C forced before mpv_create — toolkits (GTK included) call setlocale() 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 to mpv_error_string text plus the numeric code (diagnostic strings — user-facing copy is the integrator's job). Errored end-of-file arrives as Failed, not silently.
  • The update callback's threading contract: fires on mpv's render thread (Send required), plus once synchronously at registration; the crate has no main-loop opinion — bridging to GTK's spawn_local or 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 before loadfile, 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, the loadfile itself 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 plain load. 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 polling pump_events on a timer — the only timely path for audio-only use or failures while paused.
  • Property access through crate-owned types (PropertyValue, the sealed PropertyGet) — 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 ExportedFramewgpu::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_string looks for a one-off. The war story: an earlier command layer joined args into one command line, and a filename with spaces produced MPV_ERROR_INVALID_PARAMETER in production. loadfile_handles_awkward_filenames pins 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 no Drop impl to maintain. Don't reintroduce a path where a render context can outlive its core.
  • detach_render with the GL context current is the real teardown path; Drop is 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.