mpv desktop backend: hardware-accelerated, zero-copy playback on macOS and Windows - #37
Merged
Merged
Conversation
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>
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
force-pushed
the
him188/mpv-desktop-backend
branch
from
July 6, 2026 23:16
b0f33ce to
d3336fd
Compare
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>
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>
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>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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
ID3D11Deviceinto atriple-buffered ring of NT-handle shared textures, each opened as an
ID3D12Resourceon 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 at4K 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 playcorrectly instead of near-black.
Runtime & packaging
LibraryLoaderonfirst use; a single fat JVM runtime artifact works on all platforms.
@loader_pathrewrites; Windows collects DLL dependencies + a TLS CA bundle;Linux sets
RUNPATH=$ORIGIN(system libraries otherwise).including d3d11va on Windows.
Build system
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
state-machine unit tests; in-repo zero-config runtime-loading test.
CI
macos-arm64).
Known limitations