Skip to content

mpv desktop backend: hardware-accelerated, zero-copy playback on macOS and Windows - #37

Merged
StageGuard merged 33 commits into
mainfrom
him188/mpv-desktop-backend
Jul 10, 2026
Merged

StageGuard merged 33 commits into
mainfrom
him188/mpv-desktop-backend

Conversation

@Him188

@Him188 Him188 commented Jul 6, 2026

Copy link
Copy Markdown
Member

This PR supersedes #36 and delivers a production-ready mpv playback backend for
desktop — macOS and Windows, hardware-accelerated and zero-copy into the Compose
scene graph.

Rendering

macOS (Metal). A native render thread drives mpv (hwdec=videotoolbox) over an
offscreen CGL context into a triple-buffered IOSurface ring, each surface wrapped
as an MTLTexture on Skia's device.

Windows (D3D11). Via the libmpv D3D11 render API (upstream mpv-player/mpv#17764,
vendored as a patch on the 0.41.0 submodule; drop it once that PR lands in 0.42).
A native render thread drives mpv (hwdec=d3d11va) on its own ID3D11Device into a
triple-buffered ring of NT-handle shared textures, each opened as an ID3D12Resource
on Skia's D3D12 device (Compose's default Windows backend). Replaces the legacy
synchronous OpenGL path (which required forcing SKIKO_RENDER_API=OPENGL).

Both platforms draw the frame zero-copy GPU→GPU onto the Compose canvas via
nativeCanvas.drawImageRect — no per-frame GPU→CPU readback — reaching ~240fps at
4K under a full-screen danmaku overlay, with no resize crash.

HDR. PQ/bt2020 content is tone-mapped down to the 8-bit SDR render target
(gpu-dumb-mode=no + SDR target primaries/transfer), so 10-bit HDR clips play
correctly instead of near-black.

Runtime & packaging

  • Zero-config consumption: automatic runtime extraction via LibraryLoader on
    first use; a single fat JVM runtime artifact works on all platforms.
  • Self-contained artifacts: macOS bundles the external dylib closure with
    @loader_path rewrites; Windows collects DLL dependencies + a TLS CA bundle;
    Linux sets RUNPATH=$ORIGIN (system libraries otherwise).
  • FFmpeg is built with exactly the decoders/parsers/hwaccels mediamp needs,
    including d3d11va on Windows.

Build system

  • buildSrc: each module's per-platform build configuration (meson/configure flags,
    toolchain, JNI compile/link, runtime layout) is centralized into one
    *Targets.kt; task implementations are platform-agnostic data consumers.
    Verified end to end on Windows x64 and Android arm64-v8a.

Testing

  • Integration smoke test with pixel-level screenshot verification; playback
    state-machine unit tests; in-repo zero-config runtime-loading test.

CI

  • Multi-platform runtime-jar build + release workflow (windows-x64, linux-x64,
    macos-arm64).

Known limitations

  • Android cross-compilation for mpv is not yet wired into CI.
  • No windows-arm64 (upstream mpv has no D3D11 render path there).
  • The Linux runtime still relies on system libraries (libplacebo/libass/…).

Him188 and others added 11 commits July 7, 2026 07:35
Proves that libmpv with hwdec=videotoolbox can render inside the Compose
Desktop scene graph on macOS, with arbitrary Compose content composited
above the video (no airspace problem).

Pipeline: mpv render API (OpenGL over an offscreen CGL context) draws into
an IOSurface-backed FBO; the same IOSurface is wrapped as an MTLTexture on
Skia's own MTLDevice and drawn via BackendRenderTarget.makeMetal +
Surface.makeFromBackendRenderTarget inside a Compose Canvas. Zero-copy end
to end (VO stays videotoolbox[nv12]/[p010]).

Benchmarked against the current VLC stack (identical file, window and
overlay; VlcMain.kt included as the baseline): mpv uses 6-12% CPU across
1080p30/1080p60/4K60/HEVC-10bit vs VLC's 22-40%, and VLC drops frames on
44Mbps 1080p60 HEVC Main10 while mpv holds 60fps. Notably VLC also decodes
via VideoToolbox here - its cost is the GPU->CPU copyback + chroma convert
+ per-frame bitmap upload, which this prototype eliminates.

See mediamp-mpv-demo/README.md for architecture, numbers and productization
TODOs (double-buffering, bundling libmpv, folding into mediamp-mpv).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Ports the sg/mpv-rendering branch (stream_cb SeekableInput support, render
update listener, MPVLog, desktop factory/surface providers) onto main's
AbstractMediampPlayer contract, and adds the macOS render path validated in
mediamp-mpv-demo: mpv renders via the libmpv OpenGL render API on an offscreen
CGL context into an IOSurface-backed FBO, exposed to Skia as an MTLTexture on
its own Metal device (zero-copy, hwdec=videotoolbox stays on GPU).

Player rework (jvmMain):
- adapt to main's playbackState (MutableStateFlow) contract
- ms-precision positions (time-pos/duration as DOUBLE), fractional seeks
- seek gating: optimistic currentPositionMillis + drop stale reports while
  "seeking" is true, so rapid skip() accumulates without pull-backs
- eof-reached -> FINISHED (keep-open=always)
- features: PlaybackSpeed, AudioLevelController, Buffering, Screenshots,
  VideoAspectRatio, MediaMetadata (audio/subtitle tracks + chapters via
  track-list/chapter-list queries)

macOS specifics:
- render context is created eagerly at player construction: with vo=libmpv,
  mpv aborts playback ("no audio or video data played") if no render context
  exists at loadfile time
- desktop Compose surface dispatches per-OS: macOS Metal/IOSurface path,
  Windows GL-sharing path (from sg branch, unchanged)

Build/test:
- :mediamp-mpv re-enabled in settings; all targets compile
- meson JNI task compiles .mm sources + links Apple frameworks on macOS
- compileJniDevMacos: dev-only build against Homebrew libmpv (no meson)
- desktopTest: real-libmpv integration tests for URI and stream_cb playback
  driving the actual Metal render path headlessly

Verified end-to-end in Animeko via Gradle composite build: episode playback
from a real HTTP source with danmaku overlay, keyboard seek pipeline
(keyboardSeekAndFastForward -> skip) accumulating exactly.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
MediampPlayerFactoryLoader/MediampPlayerSurfaceProviderLoader pre-populate from
ServiceLoader, and register() used to append with distinctBy keeping the first
occurrence — so with multiple backends on the classpath (e.g. both mediamp-vlc
and mediamp-mpv ship service files), first() returned whichever the classpath
ordered first and explicit register() calls could never override it. Animeko
registered the mpv factory but still got VLC.

register() now prepends, making the latest explicit registration win
deterministically.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…(macOS)

Three fixes surfaced by real-pipeline probing through Animeko's
datasource-test-mcp probe_video harness:

- Frame drain: with vo=libmpv, playback stalls at position 0 unless someone
  consumes video frames. A daemon thread now discards frames via
  MPV_RENDER_PARAM_SKIP_RENDERING whenever no surface is attached (headless
  probing, background playback, surface not composed yet), so the playback
  clock always advances. Verified: headless probe plays 5s in 4.96s wall time.

- Orientation: drop MPV_RENDER_PARAM_FLIP_Y. With it, the IOSurface held the
  image bottom-up and both the on-screen render and readbacks were vertically
  flipped -- masked by the near-symmetric test pattern until captures were
  compared against ffmpeg-extracted reference frames at identical timestamps.

- Screenshots: mpv's screenshot pipeline cannot convert hwdec videotoolbox
  frames without zimg ("Input image format videotoolbox not supported by
  libswscale"). takeScreenshot now reads back our own IOSurface via
  CoreGraphics/ImageIO (nSaveSurfacePng), creating an ephemeral video-sized
  surface when none is attached; falls back to mpv's command elsewhere.

Also: SkiaMetalInterop supports MetalSwingRedrawer (swing interop rendering),
explicit loader registration precedence (previous commit), LOG() flushes per
line so bridge logs survive piping, END_FILE reason logging.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The app-integrated surface rendered black while the same code worked in a
plain window. Root causes, isolated by drawing-op matrix experiments inside
the real Animeko UI:

1. Compose (CMP 1.10) records subtrees under graphicsLayers into RenderNode
   display lists that preserve Compose-level ops only: raw nativeCanvas draws
   (Surface.draw / Canvas.drawImage on the Skia canvas) are dropped at replay.
   The video is now drawn through DrawScope.drawImage(ImageBitmap), which
   survives recording.

2. Snapshots of the BRT-wrapped external surface do not render; the frame is
   first blitted (GPU) into a Skia-owned intermediate surface whose snapshot
   is a first-class Skia texture image.

3. Skia caches wrapped-surface content by generation and never observes
   external (GL) writes: without Surface.notifyContentWillChange(DISCARD)
   before sampling, the blit reuses a stale snapshot of the first (black)
   frame forever. This was the final missing piece.

Also: glFinish instead of glFlush after the mpv render pass (a plain flush
races Metal sampling under load), END_FILE(reason/error) forwarded through
JNI to EventListener.onEndFile and mapped to PlaybackState.ERROR so business
error handlers (e.g. Animeko's automatic source switching, verified live)
react to dead sources, live redrawer re-reflection + DirectContext change
tracking, MetalSwingRedrawer support in SkiaMetalInterop.

Verified end-to-end in Animeko: real episode playback with correct picture,
subtitles and aspect ratio; dead-source auto-switching; module desktopTest
green.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
With a surface attached, frame consumption is driven by Compose redraws;
when the window is occluded or minimized Skiko stops drawing and playback
(including audio) froze. The drain thread now also discards frames when the
attached surface has not presented for >250ms, so background playback keeps
running like other backends.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…(macOS)

Moving the mouse over the player (showing overlay controls) stuttered the
video, from two compounding costs:

- Overlay animations force Compose redraws at display rate, and every redraw
  ran the full mpv render + glFinish + blit + snapshot. The expensive path now
  runs only when mpv actually produced a new frame (render-update tick);
  UI-driven redraws re-draw the cached frame image.

- Layout animations resize the video composable every frame, and each size
  change recreated the whole IOSurface/FBO/MTLTexture/Skia chain. Size changes
  are now debounced (150ms): during animation the chain keeps its old size and
  the cached frame is scaled at draw time. A page-enter animation now costs 2
  surface creations total (was one per animation frame).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…red IOSurfaces

All mpv GL work now happens on a dedicated render thread that owns the CGL
context and renders into a ring of 3 IOSurface-backed FBOs; the Compose draw
path only reads a packed atomic frame state and samples the latest buffer.
Consequences:

- Surface resizes (window resize, overlay-driven relayout) are asynchronous:
  the new ring is allocated between frames while the old generation stays
  alive (retire + consumer ack) and keeps displaying, so resizing costs no
  visible frames — this removes the last momentary hitch when the overlay UI
  appears/disappears.
- Buffers are canvas-sized again: mpv scales, letterboxes and renders
  subtitles at display resolution, and keepaspect/panscan features work.
- glFinish and the per-frame Java notification moved off the UI thread; the
  notification now fires only after the frame is actually in the IOSurface.
- The stale-surface drain heuristic is gone: the render thread always
  consumes frames, so occluded/background playback works by construction.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…ed macOS bundle

- Desktop LibraryLoader auto-extracts the classpath runtime on first MPVHandle
  creation; explicit prepareLibraries() remains as an override. Missing runtime
  now fails with an actionable message instead of a raw UnsatisfiedLinkError.
- Runtime jars name their manifest per platform (mpv-natives-<os>-<arch>.txt)
  so multiple platforms' jars can coexist on one classpath.
- mediamp-mpv-runtime aggregate is now a single fat JVM runtime variant
  depending on all published platform jars: plain JVM consumers (which carry no
  OS/arch attributes) resolve it with zero configuration.
- MpvAssembleTask bundles the full external dylib closure on macOS (libass,
  libplacebo and their transitive deps) with @loader_path rewrites and ad-hoc
  re-signing, mirroring the Windows DLL collection; the produced runtime no
  longer references /opt/homebrew.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
- Release: windows-x64 (MSYS2 UCRT64), linux-x64 (apt deps script) and
  macos-x64 (brew deps) jobs build and upload mediamp-mpv-runtime-<triple>
  jars; the publish job downloads them as prebuilt jars, builds macos-arm64
  locally and publishes all runtime artifacts plus the fat aggregate.
- Build (PR CI): each platform additionally builds its mpv runtime jar and
  verifies :mediamp-mpv:publishToMavenLocal against an isolated repo.
- mpv android cross-compilation is not wired yet; publish uses
  -Pmediamp.mpv.buildvariant=macos to exclude it. mpv has no windows-arm64
  target, that job is intentionally unchanged.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
- meson: pin -Dlibdir=lib; Debian's meson defaults to the multiarch libdir
  (lib/x86_64-linux-gnu) so the JNI link step could not find libmpv.so.
- compileJniDevMacos: skip when Homebrew mpv headers are absent (CI runners);
  the CI runtime is built via meson, the dev fast path is local-only.
- Verify mpv Maven publication: pre-quote -Dmaven.repo.local, PowerShell
  word-splits the unquoted flag into a bogus task name.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Him188 and others added 4 commits July 7, 2026 07:48
Plays an ffmpeg-generated red/blue two-segment video through the real
headless Metal render path, reads frames back via the Screenshots feature
(IOSurface readback) and asserts the center pixels match the source color —
red while playing the first segment, blue after seeking into the second.
This closes the gap where playback position advanced but nothing verified
that frames were actually rendered with correct content.

Skip reasons are now printed so silent skips are visible in CI logs.
Verified by mutation: flipping the expected color makes the test fail.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…st every branch

NativeRuntimeLoading takes the wrapper-loading step as an injected function so
tests cover extraction, manifest parsing, idempotent reconfiguration, conflicting
directories, validate short-circuits and error messages without loading real
native binaries. NativeRuntimeLoader keeps its public API and delegates.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…ode for smoke tests

- platformSuffixFor/selectMpvManifestResource/mpvRuntimeMissingMessage extracted
  as pure functions with a full os/arch matrix test.
- New :mediamp-mpv:zeroConfigTest runs MpvZeroConfigTest in a fresh JVM with the
  platform runtime jar on the classpath and no prepareLibraries call — the exact
  consumer contract. Wired into PR CI on all mpv-enabled platforms.
- -Pmediamp.mpv.test.required=true (set on the self-hosted macOS runner) turns
  silent environment skips into failures so the smoke suite cannot degrade into
  a permanently-green no-op.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Behavior-preserving extraction of the pause/paused-for-cache/eof/idle/end-file
bookkeeping from JvmMpvMediampPlayer into a pure class, plus 20 tests covering
buffering precedence, FINISHED latching, error-reason mapping, seek gating and
session lifecycle — none of which the integration smoke test exercised.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@Him188
Him188 force-pushed the him188/mpv-desktop-backend branch from b0f33ce to d3336fd Compare July 6, 2026 23:16
Him188 and others added 10 commits July 7, 2026 19:08
The zeroConfigTest is the first thing to actually load libmediampv.so on Linux
(smoke tests are macOS-only) and it caught an UnsatisfiedLinkError: unlike macOS
(@loader_path, baked in by bundleAppleExternalDependencies) and Windows
(SetDllDirectory), the Linux libraries had no way to find their siblings in the
extracted temp directory. Set RUNPATH=$ORIGIN on every bundled .so at assemble
time via patchelf, the ELF equivalent. System libraries (libass, libplacebo, ...)
are still resolved by the system linker — the Linux runtime is loadable wherever
those are present, but is not yet fully self-contained.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
currentPlatformImpl had the Linux branch commented out, so currentPlatform()
threw UnsupportedOperationException on Linux. The mpv player calls it from its
init block to choose per-platform mpv options, so constructing MpvMediampPlayer
on Linux threw during construction — which the zeroConfigTest (the first thing
to load and construct the player on Linux) surfaced after the RUNPATH fix let
the natives load. Platform.Linux already exists and is a Platform.Desktop, and
mediamp-vlc-loader already switches on it, so enabling it is the intended state.
Adds a Linux options branch (ao=pulse,alsa, vo=libmpv) mirroring macOS.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Replaces the legacy synchronous OpenGL path (wglShareLists + adopted GL
texture on Skia's GL context, which required forcing SKIKO_RENDER_API=OPENGL)
with a mirror of the macOS Metal architecture:

- Vendor mpv PR #17764 (MPV_RENDER_API_TYPE_D3D11, targeting 0.42) as
  render_d3d11.patch; the build chain applies it onto the v0.41.0 submodule.
- render_d3d11.cpp: a native render thread drives mpv on our own ID3D11Device
  (VIDEO_SUPPORT for d3d11va hwdec, WARP fallback for headless CI) into a
  triple-buffered ring of NT-handle shared textures, each opened as an
  ID3D12Resource on Skia's D3D12 device (Compose's default Windows backend).
  Frame completion is CPU-waited via a D3D11_QUERY_EVENT (the glFinish
  equivalent) before publish, so cross-device sampling never races the writer.
  Screenshot readback via staging texture + WIC PNG. The Skiko DirectXDevice
  struct pointer is slot-equality checked and QueryInterface-verified before
  the ID3D12Device is trusted.
- The consumer state machine (wrap -> blit -> snapshot, retire/ack,
  generation tracking) is extracted into MpvSurfaceRing and shared verbatim
  between macOS (BackendRenderTarget.makeMetal) and Windows (makeDirect3D);
  the Compose surface composable is likewise unified. Buffers wrap as
  RGBA_8888: RGB_888x is not wrappable on the D3D backend, and mpv's d3d11
  renderer writes alpha=1 for opaque video (pixel-verified).
- SkiaDirectXInterop reflects Direct3DRedrawer.device + contextHandler,
  same recipe as the Metal interop.
- Windows mpv options reduce to vo=libmpv + ao=wasapi; hwdec=auto now picks
  d3d11va on the render device (zero-copy end to end).
- Delete the GL path: mpv_handle_t GL members/impl, GL JNI exports,
  OpenGLComponentProvider, FrameInterpolator, GL expect/actuals.
- mediamp-mpv-demo: runD3D11 task runs the production player + surface
  against the locally assembled runtime, with an optional periodic native
  frame readback (-PscreenshotDir) for pixel verification.
- Smoke test runs on Windows against the assembled runtime (WARP-friendly).

Verified on Windows: demo and Animeko (MpvVerify) render h264 with
hwdec=d3d11va active, seek/pause/resize work, PNG readbacks pixel-correct.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The trimmed configure (--disable-everything --disable-autodetect) stripped
all hardware acceleration, so mpv's hwdec=d3d11va failed hwdevice_ctx
creation and playback silently fell back to software decoding. Enable the
d3d11va hwcontext plus the h264/hevc/vp9/av1 hwaccels.

Note: the macOS build likewise enables no videotoolbox hwaccels and needs
the same treatment (untested here, needs a macOS host to verify).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…t matching

mpvRuntimeAllElements (the dependency-only JVM variant behind the
mediamp-mpv-runtime aggregator) declared no capability, so a plain
implementation(projects.mediampMpv) could select it instead of the library
variants and drag unpublished runtime modules into resolution. Give it an
explicit capability so only the aggregator publication exposes it.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
… workflow

animeko (open-ani/animeko#3125) resolves mediamp worktree artifacts from
mavenLocal at this version during the pre-release transition; publishing and
the composite build strict constraints must agree on it.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Each module gets a single *Targets.kt as the sole source of its platform
variant configuration:

- FfmpegTargets.kt: common configure flags plus per-platform target factories
  (Windows x64/arm64, Linux, macOS, iOS, Android). FfmpegBuildTarget now also
  describes assemble-stage behavior (JNI wrapper name/link mode, MSYS2
  subsystem, DLL/TLS collection, install-name rewriting).
- MpvTargets.kt: common meson options plus per-platform factories.
  MpvBuildTarget gains MpvJniToolchain (compiler, args, link-library
  patterns, source extensions) and MpvRuntimeLayout (bin/lib layout plus a
  post-processing enum: collect DLLs / bundle dylibs / set RUNPATH / bundle
  libc++).

Task implementations become platform-agnostic data consumers: all
when(targetName)/startsWith(Macos) string dispatch in FfmpegAssembleTask,
MpvJniBuildTask and MpvAssembleTask is replaced by @input properties fed from
the descriptors. Host dispatch collapses to one enabledTargets() function per
module; the identical apply-patch/snapshot/revert triplets are extracted to
nativebuild/PatchedSourceTemplate.kt. FfmpegSupport/MpvSupport shrink to
environment lookup contexts.

Also fixes Android cross builds on fresh Windows environments (verified by
building Windows x64 + Android arm64-v8a end to end; Windows runtime output
is file-for-file identical to before the refactor):
- FfmpegConfigureTask no longer runs pacman with an empty package list
  (Android ffmpeg has no MSYS2 packages; pacman errors on no targets).
- Meson >= 1.11 validates --prefix against the host system's path semantics
  on cross builds, rejecting Windows-style prefixes when targeting Android;
  cross builds now configure --prefix=/ and install via --destdir, with
  MSYS2_ARG_CONV_EXCL protecting the bare / from MSYS2 path conversion.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
HDR videos (PQ/bt2020, e.g. 10-bit av1) played with audio but a near-black
picture on the desktop libmpv render path: the render API draws into an 8-bit
SDR (sRGB) texture, and mpv's renderer defaults to "dumb mode", a fast
passthrough blit that writes the HDR signal untonemapped, crushing everything
but the brightest highlights to black.

check_dumb_mode() only inspects scaling/debanding/shaders, never the target
colorspace, so target-prim/target-trc cannot leave dumb mode on their own.
Force the full color-management path with gpu-dumb-mode=no, then declare an SDR
output (target-prim=bt.709, target-trc=srgb, libplacebo naming) so HDR is
tone-mapped down to it. No-op for SDR sources. Applied to Windows/macOS/Linux
(all use vo=libmpv into an 8-bit SDR target); Android (vo=gpu-next) and iOS are
unaffected.

Verified on Windows in Animeko: a 3840x2160 59.94fps 10-bit av1 HDR clip
(bt2020nc/smpte2084) now plays end to end with correct color via d3d11va
hardware decoding, where it was previously black.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
… stutter

The desktop render path returned each frame as a GPU-backed Skia image and drew
it with frame.toComposeImageBitmap(). On this Compose/Skiko version that helper
reads the GPU image back to a CPU bitmap (toBitmap -> drawImage) every frame. Two
problems followed:

- Resize crash: the readback runs inside SkiaLayer.reshape()'s synchronous redraw
  during a window resize, using the image's own cached DirectContext. After the
  Direct3D swapchain is rebuilt that context is stale, so the readback dereferences
  a freed object and the JVM dies with EXCEPTION_ACCESS_VIOLATION in drawImageRect.

- Overlay stutter: the GPU->CPU readback is a full-frame transfer (~20ms at 4K)
  that serializes on the Compose render thread, pinning the whole scene -- and any
  overlay such as the danmaku canvas -- to ~40fps (measured p50 24.6ms, >20ms=99%).

Draw the frame straight onto the Compose canvas via nativeCanvas.drawImageRect
inside drawIntoCanvas: a zero-copy GPU->GPU blit on the redraw's current, valid
DirectContext. No frame crosses the CPU, so there is nothing to read back, nothing
to crash on, and nothing to stall on. The blit surface is a GPU render target again
(its snapshot is drawn, not read back). The "raw nativeCanvas draws are dropped in
RenderNode recording" concern that motivated the earlier ImageBitmap path does not
apply to drawIntoCanvas on Compose Desktop.

Verified on Windows with a 4K HDR clip under a full-screen danmaku overlay: 4.2ms
p50 (~240fps, >20ms=0%, up from ~40fps) and 90 back-to-back resizes with no crash.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@openanibot openanibot changed the title mpv desktop backend: hardware-accelerated playback with self-contained runtime publishing mpv desktop backend: hardware-accelerated, zero-copy playback on macOS and Windows Jul 9, 2026
Him188 and others added 2 commits July 9, 2026 17:24
A defensive audit of the mediamp-mpv native layer turned up several
spots where a race, an unchecked JNI result, or a trusted-but-wrong
pointer could abort the JVM. Fix them without changing behavior on the
happy path.

Lifecycle / teardown races:
- destroy() now stops the render thread (cleanup_render_resources)
  BEFORE clearing the render-update and event listeners, so no frame
  callback can touch a global ref that is about to be freed.
- clear_render_update_listener() takes render_update_listener_lock, the
  same lock notify_render_update() holds around CallVoidMethod, closing
  the window where the render thread invokes a freed listener ref.
- Introduce handle_lock and take it in the simple hot methods
  (command/set_option/get/set/observe/unobserve property) and around
  mpv_terminate_destroy in destroy(), so an in-flight native call either
  completes before teardown or sees handle_==null. The lock is never
  nested with another in those methods (seekable-stream paths stay under
  stream_registry_lock) to avoid lock-order inversion.
- MPVHandle.nativePtr becomes an AtomicLong; close() claims it with
  exchange(0) so concurrent close() calls cannot double-delete.

JNI correctness:
- nMake wraps construction in try/catch: a C++ exception unwinding across
  the JNI boundary is UB. Return 0 (the Kotlin side already checks) instead.
- find_global_class clears the pending FindClass exception unconditionally
  before returning; the old short-circuit left it pending when the class
  was missing, and the next JNI call with a pending exception is UB.
- emit_property_change guards on the cached EventListener class so a
  CallVoidMethod can never receive a null jmethodID.
- macOS nSaveSurfacePng uses scoped_utf_chars, matching the Windows path,
  so a null GetStringUTFChars (OOM) is handled instead of dereferenced.

D3D11 robustness:
- open_skia_d3d12_device validates the reflected Skiko device pointer
  (alignment + IsBadReadPtr on the slot span and the candidate vtable)
  before trusting the inferred struct layout. MinGW has no SEH, so the
  two-slot agreement and QueryInterface remain the real validation; the
  read checks just turn the most likely upgrade failure into a clean null.
- wait_for_gpu bounds its spin with a 2s timeout and logs
  GetDeviceRemovedReason, so a GPU hang/TDR cannot peg a core forever and
  wedge teardown's join on the render thread.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The old logging was ad-hoc: C++ used a bare printf LOG macro plus a
per-function "Function X started/ended" tracer, and only mpv's own
MPV_EVENT_LOG_MESSAGE lines ever reached Kotlin. Everything else was
stdout noise, and many errors were swallowed silently.

Unify it into a single sink keyed by mpv's log-level scale (client.h
mpv_log_level: lower == more severe), observable via one MPVLogHandler.

Native (C++/JNI):
- New log.h/log.cpp. log.h defines LOG(level, ...) plus level-tagged
  conveniences (LOGF/LOGE/LOGW/LOGI/LOGV/LOGD/LOGT). log.cpp dispatches
  each line to Kotlin MPVLogKt.onNativeLog via JNI (prefix "mediampv"),
  attaching the calling thread if needed, guarding against a pending JNI
  exception, and falling back to stderr when the JVM/cache is not ready so
  startup errors are never lost. The JNI-call code is extracted from the
  old emit_log_message; mpv's own lines now go through log_forward, keeping
  mpv's prefix and level.
- Remove function_printer_t / FP entirely (the started/ended tracer).
- Delete the per-frame property-change logs in emit_property_change (noise).
- Level every LOG call site per mpv rules across mpv_handle_t.cpp,
  render_d3d11.cpp, render_macos.mm, event_listener.cpp, method_cache.cpp,
  jni.cpp; strip trailing newlines (the sink adds them).

Stop swallowing errors:
- clear_jni_exception now describes+clears then logs the failure (so it
  reaches the sink instead of only stderr).
- jni_cache_classes logs loudly when a class/method fails to resolve.
- nMake logs the C++ exception before returning 0; nCommand logs overflow.
- command/set_option/set_property/observe/unobserve log the mpv error
  string on failure (previously returned false silently).
- Render/surface init failures that returned silently now log.

Kotlin:
- Redesign MPVLog into a central object: mpv-scale level constants,
  log()/error()/warn()/info()/debug()/verbose() helpers with optional
  Throwable (appended as full stack trace, never swallowed), and a
  warn+ println fallback when no handler is installed. onNativeLog and
  MPVHandle.setLogHandler funnel through it.
- Route the desktop render loggers (MpvMediampPlayerSurface, MpvSurfaceRing)
  off raw println into MPVLog with proper levels, and pass the caught
  Throwable to MPVLog.error instead of stringifying it.

Verified on Windows: JNI + Kotlin compile; mediamp-mpv desktopTest 29/30
(the one failure is the pre-existing headless screenshot-readback test,
unrelated). A live MpvVerify run shows native (mediampv), Kotlin (mediamp),
and mpv's own log lines all flowing through one handler, with zero
function-tracer or property-change spam.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Him188 and others added 6 commits July 9, 2026 20:09
Several JNI methods collapsed every distinct failure cause into a single
sentinel (0 / false), so the JVM caller learned only that "something
failed", never what. For definite, unrecoverable errors this is wrong:
the reason should reach the JVM as an exception and be handled there.

Add JNI throw helpers (throw_java_exception / throw_illegal_state /
throw_illegal_argument) that ThrowNew the right java.lang exception and
no-op when one is already pending, so an in-flight OutOfMemoryError from a
failed JNI allocation is never masked by a less specific one.

Convert the unrecoverable, info-losing paths to throw instead of returning
a sentinel, and drop their logs (the exception is now the single report):
- Construction (create): env-null / GetJavaVM failure / mpv_create failure
  throw std::runtime_error; nMake translates it to an IllegalStateException
  carrying the concrete reason (was: return 0, generic Kotlin message).
- Initialization (initialize): mpv_initialize failure / event-thread start
  failure throw; nInitialize translates to IllegalStateException.
- register_seekable_input: precondition failures throw IllegalArgument
  (null input / empty uri / not a SeekableInput), state failures throw
  IllegalState (handle destroyed, protocol registration failed, uri already
  registered). NewGlobalRef OOM is left to propagate rather than cleared.

Deliberately NOT changed (correct as-is per Java conventions):
- Recoverable results (get/set property, command, the handle-destroyed
  guard) keep returning sentinels + a WARN log — throwing there would
  destabilize normal playback and teardown.
- mpv stream_cb read/seek callbacks keep returning -1: they run on mpv's
  own threads with no Java frame to catch, so they log and return.

Make player construction exception-safe: MpvMediampPlayer.init now resolves
the native handle, then configures it inside try/catch and closes the
handle if any configuration step throws, so a failed construction never
leaks the native mpv instance.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
libmpv hard-requires the LC_NUMERIC locale to be "C": mp_create()
(player/main.c check_locale) returns NULL on any other numeric locale, and
mpv/client.h documents this as a creation precondition. The JVM can run
under a non-C numeric locale (seen on macOS CI), so mpv_create() was
returning null there and mpv never worked at all -- the old code swallowed
the null into a silent no-op, so the zero-config test's ptr != 0 check
still passed; the new "throw on create failure" surfaced it as an
IllegalStateException.

Call setlocale(LC_NUMERIC, "C") in create() (under global_guard, before
mpv_create). This affects only C number parsing/formatting used by mpv and
ffmpeg -- java.util.Locale is unaffected -- and fixes real playback on
non-C-locale systems, not just the CI test.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
skip() threw an opaque IllegalStateException on required runners without
ever showing which precondition (native dir, prepareLibraries, ffmpeg)
failed, making self-hosted CI failures undiagnosable from the logs. Print
the reason to stderr (always shown) before the required-check throws.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…omebrew

The self-hosted macOS runner sets -Pmediamp.mpv.test.required=true but has no
Homebrew libmpv, so compileJniDevMacos (gated on /opt/homebrew/include/mpv/
client.h) was skipped, MpvMediampPlayerSmokeTest's dev-native dir stayed empty,
and required mode promoted the setup skip into an IllegalStateException — turning
the whole :mediamp-mpv:allTests / Check step red on every commit.

Point the required macOS test at the meson-built runtime (mpv-output/<target>/
lib, which bundles libmediampv.dylib and the full dependency set) and depend on
mpvAssemble<target>. That runner is the one place that builds the real runtime,
so the smoke test now exercises exactly what ships instead of a Homebrew build it
doesn't have. Local macOS dev keeps the Homebrew fast-path; Windows is unchanged.

Verified locally by reproducing required mode:
:mediamp-mpv:desktopTest -Pmediamp.mpv.test.required=true -> 3 tests, 0 skipped,
0 failures (the three tests that were failing in CI).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
@StageGuard
StageGuard merged commit 94f5038 into main Jul 10, 2026
13 of 20 checks passed
@StageGuard
StageGuard deleted the him188/mpv-desktop-backend branch July 10, 2026 01:48
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants