On a Wayland session whose compositor hands clients no GPU device (Cinnamon/Muffin 6.6.3 on Mint 22.3), Mesa falls back to llvmpipe and libva-wayland's vaInitialize segfaults inside mpv_render_context_create the moment mpv is given the wl_display - the app silently closes when playback starts. 2.14 was the first release to hit this because its bundled libmpv is the first with VAAPI compiled in, and the device init runs eagerly at render-context creation, so the hardware-decoding toggle cannot avoid it. Skip MPV_RENDER_PARAM_WL_DISPLAY when GL_RENDERER names a software rasterizer: zero-copy Wayland interop does not exist on llvmpipe anyway, and mpv's hwdec=auto still reaches the GPU through a DRM render node, which works even on these sessions. Accelerated sessions keep the zero-copy handoff unchanged. Verified in a container against Muffin and weston on llvmpipe: the handle is withheld and playback works.
1561 lines
64 KiB
C++
1561 lines
64 KiB
C++
#include "mpv_player.h"
|
|
|
|
#include <epoxy/egl.h>
|
|
#include <epoxy/gl.h>
|
|
#include <flutter_linux/flutter_linux.h>
|
|
#include <gdk/gdk.h>
|
|
|
|
#ifdef GDK_WINDOWING_WAYLAND
|
|
#include <gdk/gdkwayland.h>
|
|
#endif
|
|
#include <locale.h>
|
|
|
|
// EGL 1.5 names; EGL_KHR_create_context introduced the same values earlier.
|
|
// Declared here so the build does not depend on which EGL headers the distro
|
|
// ships - the runtime check is eglCreateContext refusing the attribute, which
|
|
// the caller already falls back from.
|
|
#ifndef EGL_CONTEXT_MAJOR_VERSION
|
|
#define EGL_CONTEXT_MAJOR_VERSION EGL_CONTEXT_CLIENT_VERSION
|
|
#endif
|
|
#ifndef EGL_CONTEXT_MINOR_VERSION
|
|
#define EGL_CONTEXT_MINOR_VERSION 0x30FB
|
|
#endif
|
|
|
|
#include <chrono>
|
|
#include <cstdint>
|
|
#include <cstring>
|
|
|
|
#include "sanitize_utf8.h"
|
|
|
|
namespace {
|
|
|
|
bool EnsureProcessNumericLocale() {
|
|
// libmpv parses numeric options on worker threads, so a thread-local locale
|
|
// is insufficient. This process-wide setting intentionally remains in force
|
|
// for the rest of the process after the first player starts.
|
|
static const bool configured = setlocale(LC_NUMERIC, "C") != nullptr;
|
|
return configured;
|
|
}
|
|
|
|
// Reply userdata for the runner's own `video-params` observation.
|
|
//
|
|
// Every Dart-facing observation takes its userdata from
|
|
// PropertyObservationRegistry, which hands out 1, 2, 3, … one per distinct
|
|
// property name and never resets the counter; the two audio observations below
|
|
// pass 0, which the registry also never hands out. UINT64_MAX is the one value
|
|
// the counter cannot reach without first wrapping — and a wrap would collide
|
|
// with those two just as surely, so the scheme already depends on it not
|
|
// happening.
|
|
constexpr uint64_t kVideoParamsUserdata = UINT64_MAX;
|
|
|
|
// Runner-internal observation of the decode path mpv actually took. The
|
|
// "silent software fallback" is the one hwdec failure mode with no visible
|
|
// symptom, so every transition is logged with a timestamp from the native
|
|
// side rather than inferred from an overlay readout.
|
|
constexpr uint64_t kHwdecCurrentUserdata = UINT64_MAX - 1;
|
|
|
|
} // namespace
|
|
|
|
// Flutter on Linux uses EGL (OpenGL ES) for both X11 and Wayland.
|
|
static void* get_opengl_proc_address(void* ctx, const char* name) {
|
|
(void)ctx;
|
|
return reinterpret_cast<void*>(eglGetProcAddress(name));
|
|
}
|
|
|
|
namespace mpv {
|
|
namespace {
|
|
|
|
NativeRenderTeardownOperations ProductionTeardownOperations() {
|
|
return {
|
|
[](EGLDisplay display, EGLContext context) {
|
|
if (!eglBindAPI(EGL_OPENGL_ES_API) || !eglMakeCurrent(display, EGL_NO_SURFACE, EGL_NO_SURFACE, context)) {
|
|
g_warning("MPV: Failed to activate EGL context for teardown: 0x%x", eglGetError());
|
|
return false;
|
|
}
|
|
return true;
|
|
},
|
|
[](EGLDisplay display) {
|
|
if (!eglMakeCurrent(display, EGL_NO_SURFACE, EGL_NO_SURFACE, EGL_NO_CONTEXT)) {
|
|
g_warning("MPV: Failed to release EGL context during teardown: 0x%x", eglGetError());
|
|
return false;
|
|
}
|
|
return true;
|
|
},
|
|
[](EGLDisplay display, EGLContext context) {
|
|
if (!eglDestroyContext(display, context)) {
|
|
g_warning("MPV: Failed to destroy EGL context during teardown: 0x%x", eglGetError());
|
|
return false;
|
|
}
|
|
return true;
|
|
},
|
|
[](mpv_render_context* render) { mpv_render_context_free(render); },
|
|
[](mpv_handle* handle) { mpv_terminate_destroy(handle); },
|
|
};
|
|
}
|
|
|
|
#ifdef PLEZY_MPV_PLAYER_LIFECYCLE_TEST
|
|
NativeRenderTeardownOperations*& TestTeardownOperationsOverride() {
|
|
static NativeRenderTeardownOperations* operations = nullptr;
|
|
return operations;
|
|
}
|
|
#endif
|
|
|
|
class NativeRenderTeardownQueue {
|
|
public:
|
|
static NativeRenderTeardownQueue& Instance() {
|
|
// Native driver/libmpv teardown can block indefinitely. Keep both the
|
|
// queue and its worker state alive until the OS ends the process so static
|
|
// destruction never joins the worker or invalidates state it may access.
|
|
static NativeRenderTeardownQueue* const queue = new NativeRenderTeardownQueue();
|
|
return *queue;
|
|
}
|
|
|
|
void Enqueue(NativeRenderTeardownBatch batch) {
|
|
if (batch.resources.empty() && !batch.handle) return;
|
|
{
|
|
std::lock_guard<std::mutex> lock(mutex_);
|
|
batches_.push_back(std::move(batch));
|
|
++generation_;
|
|
}
|
|
condition_.notify_one();
|
|
}
|
|
|
|
void Retry() {
|
|
{
|
|
std::lock_guard<std::mutex> lock(mutex_);
|
|
++generation_;
|
|
}
|
|
condition_.notify_one();
|
|
}
|
|
|
|
private:
|
|
NativeRenderTeardownQueue() : worker_([this]() { Run(); }) {}
|
|
|
|
void Run() {
|
|
#ifdef PLEZY_MPV_PLAYER_LIFECYCLE_TEST
|
|
const NativeRenderTeardownOperations operations =
|
|
TestTeardownOperationsOverride() ? *TestTeardownOperationsOverride() : ProductionTeardownOperations();
|
|
#else
|
|
const NativeRenderTeardownOperations operations = ProductionTeardownOperations();
|
|
#endif
|
|
std::unique_lock<std::mutex> lock(mutex_);
|
|
for (;;) {
|
|
condition_.wait(lock, [this]() { return !batches_.empty(); });
|
|
const uint64_t observed_generation = generation_;
|
|
std::vector<NativeRenderTeardownBatch> work = std::move(batches_);
|
|
batches_.clear();
|
|
|
|
// EGL activation and mpv shutdown can block in a driver. Keep queue
|
|
// admission independent so replacement initialization and disposal only
|
|
// pay the short ownership-transfer critical section.
|
|
lock.unlock();
|
|
std::vector<NativeRenderTeardownBatch> retry;
|
|
for (auto& batch : work) {
|
|
if (!TryReleaseNativeRenderTeardown(batch, operations)) retry.push_back(std::move(batch));
|
|
}
|
|
lock.lock();
|
|
for (auto& batch : retry) batches_.push_back(std::move(batch));
|
|
|
|
if (batches_.empty()) continue;
|
|
|
|
condition_.wait_for(lock, std::chrono::milliseconds(100), [this, observed_generation]() {
|
|
return generation_ != observed_generation;
|
|
});
|
|
}
|
|
}
|
|
|
|
std::mutex mutex_;
|
|
std::condition_variable condition_;
|
|
std::vector<NativeRenderTeardownBatch> batches_;
|
|
uint64_t generation_ = 0;
|
|
std::thread worker_;
|
|
};
|
|
|
|
// Mesa's software rasterizers, as named in GL_RENDERER. The video plane lands
|
|
// on one when the compositor hands clients no GPU device (Muffin 6.6.3 does
|
|
// exactly that), and that session is the one where handing mpv the Wayland
|
|
// display is not merely futile but fatal — see the MPV_RENDER_PARAM_WL_DISPLAY
|
|
// comment in InitRenderContextForSurface.
|
|
bool IsSoftwareGlRenderer(const char* renderer) {
|
|
if (renderer == nullptr) return false;
|
|
return strstr(renderer, "llvmpipe") != nullptr || strstr(renderer, "softpipe") != nullptr ||
|
|
strstr(renderer, "swrast") != nullptr || strstr(renderer, "Software Rasterizer") != nullptr;
|
|
}
|
|
|
|
} // namespace
|
|
|
|
#ifdef PLEZY_MPV_PLAYER_LIFECYCLE_TEST
|
|
void ConfigureNativeRenderTeardownQueueForTesting(NativeRenderTeardownOperations operations) {
|
|
auto*& configured = TestTeardownOperationsOverride();
|
|
if (configured) {
|
|
*configured = std::move(operations);
|
|
} else {
|
|
configured = new NativeRenderTeardownOperations(std::move(operations));
|
|
}
|
|
}
|
|
|
|
void EnqueueNativeRenderTeardownForTesting(NativeRenderTeardownBatch batch) {
|
|
NativeRenderTeardownQueue::Instance().Enqueue(std::move(batch));
|
|
}
|
|
#endif
|
|
|
|
bool TryReleaseNativeRenderTeardown(
|
|
NativeRenderTeardownBatch& batch, const NativeRenderTeardownOperations& operations) {
|
|
for (auto it = batch.resources.begin(); it != batch.resources.end();) {
|
|
if (it->context == EGL_NO_CONTEXT || !operations.make_current(it->display, it->context)) {
|
|
++it;
|
|
continue;
|
|
}
|
|
|
|
if (it->render) {
|
|
operations.free_render(it->render);
|
|
it->render = nullptr;
|
|
}
|
|
if (!operations.release_current(it->display)) {
|
|
++it;
|
|
continue;
|
|
}
|
|
if (!operations.destroy_context(it->display, it->context)) {
|
|
++it;
|
|
continue;
|
|
}
|
|
it = batch.resources.erase(it);
|
|
}
|
|
|
|
if (!batch.resources.empty()) return false;
|
|
if (batch.handle) {
|
|
operations.terminate_handle(batch.handle);
|
|
batch.handle = nullptr;
|
|
}
|
|
batch.callback_keep_alive.reset();
|
|
return true;
|
|
}
|
|
|
|
MpvPlayer::CallbackContext::Lease::Lease(CallbackContext* context, MpvPlayer* player)
|
|
: context_(context), player_(player) {}
|
|
|
|
MpvPlayer::CallbackContext::Lease::Lease(Lease&& other) noexcept : context_(other.context_), player_(other.player_) {
|
|
other.context_ = nullptr;
|
|
other.player_ = nullptr;
|
|
}
|
|
|
|
MpvPlayer::CallbackContext::Lease& MpvPlayer::CallbackContext::Lease::operator=(Lease&& other) noexcept {
|
|
if (this != &other) {
|
|
Release();
|
|
context_ = other.context_;
|
|
player_ = other.player_;
|
|
other.context_ = nullptr;
|
|
other.player_ = nullptr;
|
|
}
|
|
return *this;
|
|
}
|
|
|
|
MpvPlayer::CallbackContext::Lease::~Lease() { Release(); }
|
|
|
|
void MpvPlayer::CallbackContext::Lease::Release() {
|
|
if (!context_) return;
|
|
context_->ReleaseLease();
|
|
context_ = nullptr;
|
|
player_ = nullptr;
|
|
}
|
|
|
|
MpvPlayer::CallbackContext::CallbackContext(MpvPlayer* player)
|
|
: player_(player), main_context_(g_main_context_ref_thread_default()) {}
|
|
|
|
MpvPlayer::CallbackContext::~CallbackContext() { g_main_context_unref(main_context_); }
|
|
|
|
MpvPlayer::CallbackContext::Lease MpvPlayer::CallbackContext::Acquire() {
|
|
std::lock_guard<std::mutex> lock(mutex_);
|
|
if (!player_) return Lease();
|
|
++in_flight_;
|
|
return Lease(this, player_);
|
|
}
|
|
|
|
void MpvPlayer::CallbackContext::WaitUntilDetached() {
|
|
std::unique_lock<std::mutex> lock(mutex_);
|
|
quiescent_.wait(lock, [this]() { return player_ == nullptr; });
|
|
}
|
|
void MpvPlayer::CallbackContext::DetachAndWait() {
|
|
std::unique_lock<std::mutex> lock(mutex_);
|
|
player_ = nullptr;
|
|
quiescent_.notify_all();
|
|
quiescent_.wait(lock, [this]() { return in_flight_ == 0; });
|
|
}
|
|
|
|
void MpvPlayer::CallbackContext::ReleaseLease() {
|
|
std::lock_guard<std::mutex> lock(mutex_);
|
|
--in_flight_;
|
|
if (in_flight_ == 0) quiescent_.notify_all();
|
|
}
|
|
|
|
struct MpvPlayer::SourceCallbackData {
|
|
explicit SourceCallbackData(std::shared_ptr<CallbackContext> callback_context)
|
|
: context(std::move(callback_context)) {}
|
|
|
|
std::shared_ptr<CallbackContext> context;
|
|
guint source_id = 0;
|
|
};
|
|
|
|
MpvPlayer::MpvPlayer(bool audio_only)
|
|
: audio_only_(audio_only), callback_context_(std::make_shared<CallbackContext>(this)) {}
|
|
|
|
MpvPlayer::~MpvPlayer() { Dispose(); }
|
|
|
|
bool MpvPlayer::IsInitialized() const {
|
|
std::lock_guard<std::mutex> lock(native_mutex_);
|
|
return mpv_ != nullptr && (audio_only_ || mpv_gl_ != nullptr);
|
|
}
|
|
|
|
bool MpvPlayer::HasMpvHandle() const {
|
|
std::lock_guard<std::mutex> lock(native_mutex_);
|
|
return mpv_ != nullptr;
|
|
}
|
|
|
|
bool MpvPlayer::Initialize() {
|
|
std::lock_guard<std::mutex> lock(native_mutex_);
|
|
if (disposed_) {
|
|
g_warning("MPV: initialization requested after disposal");
|
|
return false;
|
|
}
|
|
if (mpv_) {
|
|
return true; // Already initialized.
|
|
}
|
|
|
|
if (!EnsureProcessNumericLocale()) {
|
|
g_warning("MPV: Failed to establish the process-wide C numeric locale");
|
|
return false;
|
|
}
|
|
|
|
// Create mpv instance.
|
|
mpv_ = mpv_create();
|
|
if (!mpv_) {
|
|
g_warning("MPV: mpv_create() failed");
|
|
return false;
|
|
}
|
|
|
|
if (audio_only_) {
|
|
// Music core: no VO, no video decode. vid=no keeps embedded cover art
|
|
// from ever becoming a video track, and force-window/audio-display make
|
|
// sure mpv never opens a video output for it either.
|
|
mpv_set_option_string(mpv_, "vid", "no");
|
|
mpv_set_option_string(mpv_, "force-window", "no");
|
|
mpv_set_option_string(mpv_, "audio-display", "no");
|
|
mpv_set_option_string(mpv_, "gapless-audio", "weak");
|
|
} else {
|
|
// Configure mpv for embedded playback.
|
|
mpv_set_option_string(mpv_, "vo", "libmpv");
|
|
mpv_set_option_string(mpv_, "hwdec", "auto");
|
|
}
|
|
mpv_set_option_string(mpv_, "keep-open", "yes");
|
|
mpv_set_option_string(mpv_, "audio-fallback-to-null", "yes");
|
|
|
|
if (!audio_only_) {
|
|
// hdr-compute-peak is nested under the same predicate as the tone-map pass -
|
|
// it runs exactly when the source's declared peak exceeds target-peak - so it
|
|
// costs nothing while the compositor owns tone mapping and gives
|
|
// content-adaptive peak detection once we own it.
|
|
//
|
|
// `tone-mapping` is deliberately *not* set here: it travels with the output
|
|
// description and is applied and withdrawn in RunPendingHdrOutput instead.
|
|
// See applied_tone_mapping_ in mpv_player.h for why it cannot be global.
|
|
mpv_set_option_string(mpv_, "hdr-compute-peak", "auto");
|
|
// Declared by vo_gpu_next only, so inert for the render API this player
|
|
// runs. Set anyway so the startup value agrees with what a later
|
|
// `hdr-enabled` write puts here through SetHDREnabled.
|
|
mpv_set_option_string(mpv_, "target-colorspace-hint", plezy::mpv_common::TargetColorspaceHint(hdr_enabled_));
|
|
}
|
|
mpv_set_option_string(mpv_, "idle", "yes");
|
|
mpv_set_option_string(mpv_, "input-default-bindings", "no");
|
|
mpv_set_option_string(mpv_, "input-vo-keyboard", "no");
|
|
mpv_set_option_string(mpv_, "osc", "no");
|
|
mpv_set_option_string(mpv_, "terminal", "no");
|
|
// Every URL Plezy opens is a media-server stream or a local file, never a
|
|
// site mpv's bundled ytdl_hook could resolve. Loading it costs an on_load
|
|
// hook per open and, on a failed open, spawns yt-dlp with the full stream
|
|
// URL — access token included — in its argv, where /proc exposes it. mpv
|
|
// gates loading the builtin script on this option at mpv_initialize time,
|
|
// so it has to be set here rather than from Dart.
|
|
mpv_set_option_string(mpv_, "ytdl", "no");
|
|
|
|
// Default to info-level logging. The vaapi hwdec probe and the "Using
|
|
// software decoding" fallback are MSGL_INFO messages, and both are the only
|
|
// evidence a silently software-decoding session leaves behind; at "warn"
|
|
// neither ever reaches the app log (mpv_request_log_messages takes a single
|
|
// global level - there is no per-module syntax here), so a hwdec regression
|
|
// is indistinguishable from a working one. Debug logging raises this
|
|
// further via setLogLevel.
|
|
mpv_request_log_messages(mpv_, "info");
|
|
|
|
// Initialize mpv.
|
|
int err = mpv_initialize(mpv_);
|
|
if (err < 0) {
|
|
g_warning("MPV: mpv_initialize() failed: %s", mpv_error_string(err));
|
|
mpv_destroy(mpv_);
|
|
mpv_ = nullptr;
|
|
return false;
|
|
}
|
|
|
|
// Set up event wakeup callback.
|
|
mpv_set_wakeup_callback(mpv_, OnMpvWakeup, callback_context_.get());
|
|
mpv_observe_property(mpv_, 0, "current-ao", MPV_FORMAT_STRING);
|
|
mpv_observe_property(mpv_, 0, "audio-device-list", MPV_FORMAT_NONE);
|
|
if (!audio_only_) {
|
|
// One node observation stands in for six blocking sub-property reads. The
|
|
// HDR decision runs on the GTK main thread and one of its callers fires on
|
|
// every seek, while libmpv's synchronous read hands the request to the core
|
|
// and waits for the playloop; every other property access here is async for
|
|
// exactly that reason.
|
|
//
|
|
// An audio-only core has no video-params to report, so it is not asked.
|
|
source_hdr_metadata_ = SourceHdrMetadata();
|
|
mpv_observe_property(mpv_, kVideoParamsUserdata, "video-params", MPV_FORMAT_NODE);
|
|
// Which decode path is in use. mpv only emits on change, so each event is
|
|
// a real transition worth a log line.
|
|
mpv_observe_property(mpv_, kHwdecCurrentUserdata, "hwdec-current", MPV_FORMAT_STRING);
|
|
}
|
|
|
|
g_message("MPV: Initialization successful (%s)", audio_only_ ? "audio-only" : "render context deferred");
|
|
return true;
|
|
}
|
|
|
|
void MpvPlayer::RetryPendingNativeTeardown() { NativeRenderTeardownQueue::Instance().Retry(); }
|
|
|
|
bool MpvPlayer::InitRenderContextForSurface(EGLDisplay display, EGLConfig config, EGLSurface surface, int depth_bits) {
|
|
RetryPendingNativeTeardown();
|
|
|
|
std::lock_guard<std::mutex> lock(native_mutex_);
|
|
if (audio_only_ || disposed_) {
|
|
g_warning("MPV: Video-plane render context requested for an unavailable player");
|
|
return false;
|
|
}
|
|
if (mpv_gl_) return true;
|
|
if (!mpv_) {
|
|
g_warning("MPV: Cannot create render context - mpv not initialized");
|
|
return false;
|
|
}
|
|
if (display == EGL_NO_DISPLAY || surface == EGL_NO_SURFACE) {
|
|
g_warning("MPV: Video plane provided no usable EGL display or surface");
|
|
return false;
|
|
}
|
|
if (!eglBindAPI(EGL_OPENGL_ES_API)) {
|
|
g_warning("MPV: Failed to bind OpenGL ES API: 0x%x", eglGetError());
|
|
return false;
|
|
}
|
|
|
|
// Nothing is shared with Flutter here, so take the highest ES version the
|
|
// driver will give. EGL_CONTEXT_CLIENT_VERSION=3 asks for exactly 3.0, which
|
|
// reads as "ES 3" while being the oldest of them; 3.2 brings float render
|
|
// targets (mpv picks an rgba16f FBO on this path) and ES 3.1 semantics for
|
|
// the rest.
|
|
//
|
|
// It does **not** buy compute shaders. mpv refuses them on any GLES context at
|
|
// any version, by construction (`video/out/opengl/ra_gl.c:136-139` in v0.40.0):
|
|
//
|
|
// // While we can handle compute shaders on GLES the spec (intentionally)
|
|
// // does not support binding textures for writing, which all uses inside
|
|
// // mpv would require. So disable it unconditionally anyway.
|
|
// if (ra->glsl_es) ra->caps &= ~RA_CAP_COMPUTE;
|
|
//
|
|
// So `hdr-compute-peak` is unreachable through the render API here however
|
|
// new the context is - confirmed on hardware with an ES 3.2 context whose
|
|
// glDispatchCompute and glBindImageTexture both resolve, where mpv still
|
|
// logs "Disabling HDR peak computation (compute shaders=0)". The player-side
|
|
// tone map therefore aims at the peak the source declares rather than one
|
|
// measured from the frames. Reaching it needs a desktop-GL context on the
|
|
// plane, which is the same architectural door as libplacebo and belongs with
|
|
// it rather than in a version bump.
|
|
//
|
|
// EGL_CONTEXT_MINOR_VERSION needs EGL 1.5 or EGL_KHR_create_context. Where
|
|
// neither is present eglCreateContext rejects the attribute outright, so the
|
|
// legacy CLIENT_VERSION-only request stays as the floor rather than letting
|
|
// a missing extension fail context creation altogether.
|
|
struct EsVersion {
|
|
EGLint major;
|
|
EGLint minor;
|
|
};
|
|
static constexpr EsVersion kPreferredEsVersions[] = {{3, 2}, {3, 1}, {3, 0}, {2, 0}};
|
|
EGLContext candidate_context = EGL_NO_CONTEXT;
|
|
EGLint chosen_major = 0;
|
|
EGLint chosen_minor = 0;
|
|
for (const EsVersion& version : kPreferredEsVersions) {
|
|
const EGLint context_attribs[] = {
|
|
EGL_CONTEXT_MAJOR_VERSION, version.major, EGL_CONTEXT_MINOR_VERSION, version.minor, EGL_NONE};
|
|
candidate_context = eglCreateContext(display, config, EGL_NO_CONTEXT, context_attribs);
|
|
if (candidate_context != EGL_NO_CONTEXT) {
|
|
chosen_major = version.major;
|
|
chosen_minor = version.minor;
|
|
break;
|
|
}
|
|
}
|
|
if (candidate_context == EGL_NO_CONTEXT) {
|
|
for (const EGLint client_version : {3, 2}) {
|
|
const EGLint context_attribs[] = {EGL_CONTEXT_CLIENT_VERSION, client_version, EGL_NONE};
|
|
candidate_context = eglCreateContext(display, config, EGL_NO_CONTEXT, context_attribs);
|
|
if (candidate_context != EGL_NO_CONTEXT) {
|
|
chosen_major = client_version;
|
|
chosen_minor = 0;
|
|
break;
|
|
}
|
|
}
|
|
}
|
|
if (candidate_context == EGL_NO_CONTEXT) {
|
|
g_warning("MPV: Failed to create the video-plane EGL context: 0x%x", eglGetError());
|
|
return false;
|
|
}
|
|
// Report the requested version, not the delivered one - those are different
|
|
// claims, and the delivered one is logged below once a context is current.
|
|
g_message("MPV video plane: requested OpenGL ES %d.%d", chosen_major, chosen_minor);
|
|
|
|
auto destroy_candidate_context = [&]() {
|
|
if (eglGetCurrentContext() == candidate_context) {
|
|
eglMakeCurrent(display, EGL_NO_SURFACE, EGL_NO_SURFACE, EGL_NO_CONTEXT);
|
|
}
|
|
if (!eglDestroyContext(display, candidate_context)) {
|
|
g_warning("MPV: Failed to destroy rejected video-plane EGL context: 0x%x", eglGetError());
|
|
}
|
|
};
|
|
|
|
if (!eglMakeCurrent(display, surface, surface, candidate_context)) {
|
|
g_warning("MPV: Failed to activate the video-plane EGL context: 0x%x", eglGetError());
|
|
destroy_candidate_context();
|
|
return false;
|
|
}
|
|
|
|
// What the driver actually gave, and whether mpv will find the entry points
|
|
// its compute path needs. Asking for a version is not the same as getting
|
|
// it, and mpv's own report of "compute shaders=0" says nothing about which
|
|
// half is missing. All of these are cheap and all were needed to diagnose
|
|
// this. The renderer name additionally decides the hwdec display handoff
|
|
// below.
|
|
const GLubyte* gl_version = glGetString(GL_VERSION);
|
|
const GLubyte* gl_renderer = glGetString(GL_RENDERER);
|
|
const bool software_renderer = IsSoftwareGlRenderer(reinterpret_cast<const char*>(gl_renderer));
|
|
g_message(
|
|
"MPV video plane: GL_VERSION='%s' GL_RENDERER='%s' dispatch_compute=%s image_load_store=%s",
|
|
gl_version ? reinterpret_cast<const char*>(gl_version) : "(null)",
|
|
gl_renderer ? reinterpret_cast<const char*>(gl_renderer) : "(null)",
|
|
eglGetProcAddress("glDispatchCompute") ? "yes" : "no", eglGetProcAddress("glBindImageTexture") ? "yes" : "no");
|
|
|
|
// Pre-flight the VAAPI dmabuf interop prerequisites. mpv's GL-side probe
|
|
// (dmabuf_interop_gl_init) is lazy — first hardware decode attempt — and its
|
|
// failure never fails mpv_render_context_create, so a driver that lacks the
|
|
// pieces quietly decodes everything in software. Naming which prerequisite
|
|
// is missing on this display/context turns that into a diagnosable
|
|
// one-liner. The three extensions are the ones the probe requires;
|
|
// EGL_EXT_image_dma_buf_import is the display-level one, GL_OES_EGL_image is
|
|
// context-level. (The VAAPI *device* init is a different story: given a
|
|
// Wayland display below, mpv opens it eagerly inside
|
|
// mpv_render_context_create.)
|
|
const char* egl_exts = eglQueryString(display, EGL_EXTENSIONS);
|
|
const GLubyte* gl_exts = glGetString(GL_EXTENSIONS);
|
|
const bool has_dma_buf = egl_exts != nullptr && strstr(egl_exts, "EGL_EXT_image_dma_buf_import") != nullptr;
|
|
const bool has_image_base = egl_exts != nullptr && strstr(egl_exts, "EGL_KHR_image_base") != nullptr;
|
|
const bool has_oes_egl_image =
|
|
gl_exts != nullptr && strstr(reinterpret_cast<const char*>(gl_exts), "GL_OES_EGL_image") != nullptr;
|
|
if (!has_dma_buf || !has_image_base || !has_oes_egl_image) {
|
|
g_warning(
|
|
"MPV video plane: VAAPI dmabuf interop prerequisites missing "
|
|
"(EGL_EXT_image_dma_buf_import=%d EGL_KHR_image_base=%d GL_OES_EGL_image=%d); "
|
|
"hardware decoding may silently fall back to software",
|
|
has_dma_buf, has_image_base, has_oes_egl_image);
|
|
}
|
|
|
|
// Now that a context is current, the surface's swap interval can be set.
|
|
// eglSwapBuffers runs on the GTK main thread and must never block: at the
|
|
// default interval Mesa throttles it on the compositor's frame callback,
|
|
// which an occluded surface never receives. The plane paces itself with its
|
|
// own frame callback instead.
|
|
if (!eglSwapInterval(display, 0)) {
|
|
g_warning("MPV: could not disable EGL swap throttling on the video plane: 0x%x", eglGetError());
|
|
}
|
|
|
|
mpv_opengl_init_params gl_init_params{};
|
|
gl_init_params.get_proc_address = get_opengl_proc_address;
|
|
gl_init_params.get_proc_address_ctx = nullptr;
|
|
mpv_render_param params[] = {
|
|
{MPV_RENDER_PARAM_API_TYPE, const_cast<char*>(MPV_RENDER_API_TYPE_OPENGL)},
|
|
{MPV_RENDER_PARAM_OPENGL_INIT_PARAMS, &gl_init_params},
|
|
{MPV_RENDER_PARAM_INVALID, nullptr},
|
|
{MPV_RENDER_PARAM_INVALID, nullptr},
|
|
};
|
|
|
|
// The plane only exists on Wayland, and hwdec interop wants the display
|
|
// handle: without it VAAPI has to find a device by other means and can
|
|
// quietly end up on software decoding, on the path that exists for
|
|
// performance.
|
|
//
|
|
// Never on a software renderer, though. Zero-copy interop into llvmpipe does
|
|
// not exist, so the handle buys nothing — and the one session that produces
|
|
// a software renderer on the plane (a compositor that hands clients no GPU
|
|
// device; Muffin 6.6.3, issue #1963) is also the one where libva-wayland's
|
|
// vaInitialize segfaults on that handle, inside mpv_render_context_create,
|
|
// taking the app down before playback starts. Left without a display handle,
|
|
// mpv's hwdec=auto probes the DRM render nodes instead, which still works on
|
|
// such a session (the kernel driver is fine; only the compositor's device
|
|
// handoff is broken).
|
|
#ifdef GDK_WINDOWING_WAYLAND
|
|
GdkDisplay* gdk_display = gdk_display_get_default();
|
|
if (GDK_IS_WAYLAND_DISPLAY(gdk_display) && !software_renderer) {
|
|
params[2].type = MPV_RENDER_PARAM_WL_DISPLAY;
|
|
params[2].data = gdk_wayland_display_get_wl_display(gdk_display);
|
|
} else if (software_renderer) {
|
|
g_message("MPV video plane: software GL renderer; not handing mpv the Wayland display for VAAPI interop");
|
|
}
|
|
#endif
|
|
|
|
mpv_render_context* candidate_gl = nullptr;
|
|
const int error = mpv_render_context_create(&candidate_gl, mpv_, params);
|
|
if (error < 0 || candidate_gl == nullptr) {
|
|
g_warning("MPV: mpv_render_context_create() failed for the video plane: %s", mpv_error_string(error));
|
|
if (candidate_gl) mpv_render_context_free(candidate_gl);
|
|
destroy_candidate_context();
|
|
return false;
|
|
}
|
|
|
|
egl_display_ = display;
|
|
egl_context_ = candidate_context;
|
|
surface_depth_bits_ = depth_bits > 0 ? depth_bits : 8;
|
|
mpv_gl_ = candidate_gl;
|
|
mpv_render_context_set_update_callback(mpv_gl_, OnMpvRenderUpdate, callback_context_.get());
|
|
g_message("MPV: Render context created on the Wayland video plane");
|
|
return true;
|
|
}
|
|
|
|
bool MpvPlayer::RenderToSurface(EGLSurface surface, int width, int height) {
|
|
std::lock_guard<std::mutex> lock(native_mutex_);
|
|
if (disposed_ || !mpv_gl_ || egl_context_ == EGL_NO_CONTEXT || surface == EGL_NO_SURFACE) return false;
|
|
if (width < 1 || height < 1) return false;
|
|
|
|
if (!eglBindAPI(EGL_OPENGL_ES_API) || !eglMakeCurrent(egl_display_, surface, surface, egl_context_)) {
|
|
g_warning("MPV: Failed to activate the video-plane EGL context for render: 0x%x", eglGetError());
|
|
return false;
|
|
}
|
|
|
|
// Consume the redraw latch before rendering: OnMpvRenderUpdate drops further
|
|
// notifications until it is cleared.
|
|
needs_redraw_.store(false);
|
|
|
|
mpv_opengl_fbo mpv_fbo{};
|
|
mpv_fbo.fbo = 0; // the window surface's default framebuffer
|
|
mpv_fbo.w = width;
|
|
mpv_fbo.h = height;
|
|
// Ignored by the render API's OpenGL backend, which reads the depth param
|
|
// instead, but it is what mpv#16818's gpu-next backend will read, so state
|
|
// it truthfully rather than leave a lie in place for that day.
|
|
mpv_fbo.internal_format = surface_depth_bits_ >= 16 ? GL_RGBA16F : surface_depth_bits_ >= 10 ? GL_RGB10_A2 : GL_RGBA8;
|
|
|
|
// The default framebuffer is bottom-up relative to mpv's image orientation,
|
|
// so this flips.
|
|
int flip_y = 1;
|
|
// Without this mpv assumes 8 bits and dithers a 10-bit PQ plane down to 8,
|
|
// which bands precisely in the dark ramp PQ spends most of its code space on.
|
|
int depth = surface_depth_bits_;
|
|
mpv_render_param params[] = {
|
|
{MPV_RENDER_PARAM_OPENGL_FBO, &mpv_fbo},
|
|
{MPV_RENDER_PARAM_FLIP_Y, &flip_y},
|
|
{MPV_RENDER_PARAM_DEPTH, &depth},
|
|
{MPV_RENDER_PARAM_INVALID, nullptr},
|
|
};
|
|
mpv_render_context_render(mpv_gl_, params);
|
|
return true;
|
|
}
|
|
|
|
void MpvPlayer::Dispose() {
|
|
if (disposed_.exchange(true)) {
|
|
return;
|
|
}
|
|
|
|
// Stop native producers before revoking access to the player. A callback
|
|
// already entered on an mpv thread owns a lease and is allowed to finish.
|
|
{
|
|
std::lock_guard<std::mutex> lock(native_mutex_);
|
|
if (mpv_) {
|
|
const char* stop_command[] = {"stop", nullptr};
|
|
const int stop_result = mpv_command_async(mpv_, 0, stop_command);
|
|
if (stop_result < 0) {
|
|
g_warning("MPV: Failed to enqueue stop during disposal: %s", mpv_error_string(stop_result));
|
|
}
|
|
}
|
|
if (mpv_gl_) {
|
|
mpv_render_context_set_update_callback(mpv_gl_, nullptr, nullptr);
|
|
}
|
|
if (mpv_) {
|
|
mpv_set_wakeup_callback(mpv_, nullptr, nullptr);
|
|
}
|
|
}
|
|
callback_context_->DetachAndWait();
|
|
|
|
{
|
|
std::lock_guard<std::mutex> lock(callback_mutex_);
|
|
redraw_callback_ = nullptr;
|
|
event_callback_ = nullptr;
|
|
source_metadata_callback_ = nullptr;
|
|
}
|
|
|
|
auto cancelled = pending_requests_.CancelAll();
|
|
for (auto& callback : cancelled.status) {
|
|
callback(MPV_ERROR_UNINITIALIZED);
|
|
}
|
|
for (auto& callback : cancelled.properties) {
|
|
callback(-1, "");
|
|
}
|
|
|
|
RemoveTrackedSources();
|
|
|
|
// The plane's context is left current on this thread by RenderToSurface and
|
|
// nothing else releases it before the video surface is destroyed - which the
|
|
// plugin does *after* this call. An EGLContext can be current to at most one
|
|
// thread, so handing it to the teardown worker while it is still bound here
|
|
// makes the worker's eglMakeCurrent fail with EGL_BAD_ACCESS; the pair is then
|
|
// retained, and by the note below the mpv handle cannot be terminated until
|
|
// every pair drains. Repeated open/close would carry a whole stale mpv core
|
|
// across each gap. Only our own context is released: Flutter's must be left
|
|
// exactly where it is.
|
|
if (egl_context_ != EGL_NO_CONTEXT && eglGetCurrentContext() == egl_context_) {
|
|
if (!eglMakeCurrent(egl_display_, EGL_NO_SURFACE, EGL_NO_SURFACE, EGL_NO_CONTEXT)) {
|
|
g_warning("MPV: Failed to release the video-plane EGL context before teardown: 0x%x", eglGetError());
|
|
}
|
|
}
|
|
|
|
// Transfer the render context, the EGL context and the shared mpv handle to
|
|
// the managed teardown thread. A failed EGL bind leaves the complete pair in
|
|
// the queue; the handle cannot be terminated until every pair is gone.
|
|
NativeRenderTeardownBatch teardown;
|
|
{
|
|
std::lock_guard<std::mutex> lock(native_mutex_);
|
|
if (mpv_gl_ || egl_context_ != EGL_NO_CONTEXT) {
|
|
teardown.resources.push_back({mpv_gl_, egl_display_, egl_context_});
|
|
}
|
|
teardown.handle = mpv_;
|
|
teardown.callback_keep_alive = callback_context_;
|
|
mpv_gl_ = nullptr;
|
|
mpv_ = nullptr;
|
|
egl_display_ = EGL_NO_DISPLAY;
|
|
egl_context_ = EGL_NO_CONTEXT;
|
|
// The next player must not decide against this one's colour space.
|
|
source_hdr_metadata_ = SourceHdrMetadata();
|
|
}
|
|
NativeRenderTeardownQueue::Instance().Enqueue(std::move(teardown));
|
|
|
|
observed_properties_.Clear();
|
|
}
|
|
|
|
void MpvPlayer::Command(const std::vector<std::string>& args) { CommandAsync(args, nullptr); }
|
|
|
|
void MpvPlayer::CommandAsync(const std::vector<std::string>& args, CommandCallback callback) {
|
|
if (disposed_ || !mpv_) {
|
|
if (callback) callback(MPV_ERROR_UNINITIALIZED);
|
|
return;
|
|
}
|
|
|
|
plezy::mpv_common::SubmitCommandAsync(mpv_, pending_requests_, args, std::move(callback));
|
|
}
|
|
|
|
void MpvPlayer::SetProperty(const std::string& name, const std::string& value) {
|
|
SetPropertyAsync(name, value, nullptr);
|
|
}
|
|
|
|
void MpvPlayer::SetPropertyAsync(const std::string& name, const std::string& value, StatusCallback callback) {
|
|
#ifdef PLEZY_MPV_PLAYER_LIFECYCLE_TEST
|
|
// Ahead of the handle check: a substituted writer stands in for the core, so
|
|
// the absence of a real one is not a reason to refuse the write.
|
|
if (test_property_write_) {
|
|
test_property_write_(name, value, std::move(callback));
|
|
return;
|
|
}
|
|
#endif
|
|
if (disposed_ || !mpv_) {
|
|
if (callback) callback(MPV_ERROR_UNINITIALIZED);
|
|
return;
|
|
}
|
|
|
|
if (name == "hdr-enabled") {
|
|
SetHDREnabled(plezy::mpv_common::ParseEnabledFlag(value), std::move(callback));
|
|
return;
|
|
}
|
|
plezy::mpv_common::SubmitSetPropertyAsync(
|
|
mpv_, pending_requests_, name, value, [this, name, value, cb = std::move(callback)](int error) mutable {
|
|
// Native-side attribution for the same failure the platform channel
|
|
// reports: the HDR transaction's property writes never reach the
|
|
// channel handler, so without this a refused target-* write leaves
|
|
// only mpv's error string in the log. Values are truncated the same
|
|
// way the channel error description is, so a token or URL that lands
|
|
// in a property value stays bounded.
|
|
if (error < 0 && !disposed_) {
|
|
std::string logged = value;
|
|
if (logged.size() > plezy::mpv_common::kSetPropertyErrorDescriptionLimit) {
|
|
logged.resize(plezy::mpv_common::kSetPropertyErrorDescriptionLimit);
|
|
}
|
|
g_warning("MPV: setProperty '%s'='%s' failed: %s", name.c_str(), logged.c_str(), mpv_error_string(error));
|
|
}
|
|
if (cb) cb(error);
|
|
});
|
|
}
|
|
|
|
bool MpvPlayer::ReadSourceHdrMetadata(SourceHdrMetadata* out) {
|
|
if (out == nullptr) return false;
|
|
std::lock_guard<std::mutex> lock(native_mutex_);
|
|
if (disposed_ || !mpv_) return false;
|
|
*out = source_hdr_metadata_;
|
|
return true;
|
|
}
|
|
|
|
void MpvPlayer::UpdateSourceHdrMetadata(const mpv_node* params) {
|
|
{
|
|
std::lock_guard<std::mutex> lock(native_mutex_);
|
|
source_hdr_metadata_ = ParseSourceHdrMetadata(params);
|
|
}
|
|
|
|
// Outside the lock on purpose: the callback's whole job is to re-run the HDR
|
|
// decision, which reads the cache straight back through ReadSourceHdrMetadata.
|
|
SourceMetadataCallback callback;
|
|
{
|
|
std::lock_guard<std::mutex> lock(callback_mutex_);
|
|
callback = source_metadata_callback_;
|
|
}
|
|
if (callback) callback();
|
|
}
|
|
|
|
void MpvPlayer::SetSourceMetadataCallback(SourceMetadataCallback callback) {
|
|
std::lock_guard<std::mutex> lock(callback_mutex_);
|
|
source_metadata_callback_ = std::move(callback);
|
|
}
|
|
|
|
void MpvPlayer::GetPropertyAsync(const std::string& name, GetPropertyCallback callback) {
|
|
if (disposed_ || !mpv_) {
|
|
if (callback) callback(MPV_ERROR_UNINITIALIZED, "");
|
|
return;
|
|
}
|
|
|
|
plezy::mpv_common::SubmitGetPropertyAsync(mpv_, pending_requests_, name, std::move(callback));
|
|
}
|
|
|
|
void MpvPlayer::ObserveProperty(const std::string& name, const std::string& format, int id) {
|
|
if (disposed_ || !mpv_) return;
|
|
|
|
const auto request = observed_properties_.Register(name, format, id);
|
|
if (!request.added) return;
|
|
mpv_observe_property(mpv_, request.userdata, name.c_str(), request.format);
|
|
}
|
|
|
|
void MpvPlayer::SetEventCallback(EventCallback callback) {
|
|
std::lock_guard<std::mutex> lock(callback_mutex_);
|
|
event_callback_ = std::move(callback);
|
|
}
|
|
|
|
void MpvPlayer::SetRedrawCallback(RedrawCallback callback) {
|
|
std::lock_guard<std::mutex> lock(callback_mutex_);
|
|
redraw_callback_ = std::move(callback);
|
|
}
|
|
|
|
void MpvPlayer::SetLogLevel(const std::string& level) {
|
|
if (disposed_ || !mpv_) return;
|
|
mpv_request_log_messages(mpv_, level.c_str());
|
|
}
|
|
|
|
void MpvPlayer::OnMpvWakeup(void* ctx) {
|
|
auto* context = static_cast<CallbackContext*>(ctx);
|
|
auto lease = context->Acquire();
|
|
if (!lease) return;
|
|
|
|
MpvPlayer* player = lease.player();
|
|
if (!player->disposed_) {
|
|
player->ScheduleWakeupSource();
|
|
}
|
|
}
|
|
|
|
void MpvPlayer::OnMpvRenderUpdate(void* ctx) {
|
|
auto* context = static_cast<CallbackContext*>(ctx);
|
|
auto lease = context->Acquire();
|
|
if (!lease) return;
|
|
|
|
MpvPlayer* player = lease.player();
|
|
if (player->disposed_) return;
|
|
|
|
bool expected = false;
|
|
if (!player->needs_redraw_.compare_exchange_strong(expected, true)) {
|
|
return;
|
|
}
|
|
|
|
// The redraw must run on the player's owning GLib context, never on mpv's
|
|
// render/VO thread.
|
|
player->ScheduleRedrawSource();
|
|
}
|
|
|
|
void MpvPlayer::DestroySourceCallbackData(gpointer data) { delete static_cast<SourceCallbackData*>(data); }
|
|
|
|
void MpvPlayer::ScheduleWakeupSource() {
|
|
std::lock_guard<std::mutex> lock(source_mutex_);
|
|
if (disposed_ || wakeup_source_id_ != 0) return;
|
|
|
|
GSource* source = g_idle_source_new();
|
|
g_source_set_priority(source, G_PRIORITY_HIGH_IDLE);
|
|
auto* data = new SourceCallbackData(callback_context_);
|
|
g_source_set_callback(source, DispatchWakeupSource, data, DestroySourceCallbackData);
|
|
data->source_id = g_source_attach(source, callback_context_->main_context());
|
|
wakeup_source_id_ = data->source_id;
|
|
g_source_unref(source);
|
|
}
|
|
|
|
void MpvPlayer::ScheduleRedrawSource() {
|
|
std::lock_guard<std::mutex> lock(source_mutex_);
|
|
if (disposed_ || redraw_source_id_ != 0) return;
|
|
|
|
GSource* source = g_idle_source_new();
|
|
auto* data = new SourceCallbackData(callback_context_);
|
|
g_source_set_callback(source, DispatchRedrawSource, data, DestroySourceCallbackData);
|
|
data->source_id = g_source_attach(source, callback_context_->main_context());
|
|
redraw_source_id_ = data->source_id;
|
|
g_source_unref(source);
|
|
|
|
if (redraw_source_id_ == 0) {
|
|
needs_redraw_ = false;
|
|
}
|
|
}
|
|
|
|
void MpvPlayer::ScheduleRecoverySource() {
|
|
std::lock_guard<std::mutex> lock(source_mutex_);
|
|
if (disposed_ || recovery_source_id_ != 0) return;
|
|
|
|
GSource* source = g_timeout_source_new(100);
|
|
auto* data = new SourceCallbackData(callback_context_);
|
|
g_source_set_callback(source, DispatchRecoverySource, data, DestroySourceCallbackData);
|
|
data->source_id = g_source_attach(source, callback_context_->main_context());
|
|
recovery_source_id_ = data->source_id;
|
|
g_source_unref(source);
|
|
}
|
|
|
|
gboolean MpvPlayer::DispatchWakeupSource(gpointer data) {
|
|
auto* source_data = static_cast<SourceCallbackData*>(data);
|
|
auto lease = source_data->context->Acquire();
|
|
if (!lease) return G_SOURCE_REMOVE;
|
|
|
|
MpvPlayer* player = lease.player();
|
|
{
|
|
std::lock_guard<std::mutex> lock(player->source_mutex_);
|
|
if (player->wakeup_source_id_ == source_data->source_id) {
|
|
player->wakeup_source_id_ = 0;
|
|
}
|
|
}
|
|
if (!player->disposed_ && player->mpv_) {
|
|
player->ProcessEvents();
|
|
}
|
|
return G_SOURCE_REMOVE;
|
|
}
|
|
|
|
gboolean MpvPlayer::DispatchRedrawSource(gpointer data) {
|
|
auto* source_data = static_cast<SourceCallbackData*>(data);
|
|
auto lease = source_data->context->Acquire();
|
|
if (!lease) return G_SOURCE_REMOVE;
|
|
|
|
MpvPlayer* player = lease.player();
|
|
{
|
|
std::lock_guard<std::mutex> lock(player->source_mutex_);
|
|
if (player->redraw_source_id_ == source_data->source_id) {
|
|
player->redraw_source_id_ = 0;
|
|
}
|
|
}
|
|
if (player->disposed_) return G_SOURCE_REMOVE;
|
|
|
|
RedrawCallback callback;
|
|
{
|
|
std::lock_guard<std::mutex> lock(player->callback_mutex_);
|
|
callback = player->redraw_callback_;
|
|
}
|
|
if (callback) callback();
|
|
return G_SOURCE_REMOVE;
|
|
}
|
|
|
|
gboolean MpvPlayer::DispatchRecoverySource(gpointer data) {
|
|
auto* source_data = static_cast<SourceCallbackData*>(data);
|
|
auto lease = source_data->context->Acquire();
|
|
if (!lease) return G_SOURCE_REMOVE;
|
|
|
|
MpvPlayer* player = lease.player();
|
|
if (player->disposed_) return G_SOURCE_REMOVE;
|
|
|
|
player->MaybeRunAudioRecovery();
|
|
if (player->audio_recovery_.HasPendingWork()) {
|
|
return G_SOURCE_CONTINUE;
|
|
}
|
|
|
|
std::lock_guard<std::mutex> lock(player->source_mutex_);
|
|
if (player->recovery_source_id_ == source_data->source_id) {
|
|
player->recovery_source_id_ = 0;
|
|
}
|
|
return G_SOURCE_REMOVE;
|
|
}
|
|
|
|
void MpvPlayer::RemoveTrackedSources() {
|
|
std::lock_guard<std::mutex> lock(source_mutex_);
|
|
GMainContext* context = callback_context_->main_context();
|
|
auto remove = [context](guint& source_id) {
|
|
if (source_id == 0) return;
|
|
GSource* source = g_main_context_find_source_by_id(context, source_id);
|
|
if (source) g_source_destroy(source);
|
|
source_id = 0;
|
|
};
|
|
remove(wakeup_source_id_);
|
|
remove(redraw_source_id_);
|
|
remove(recovery_source_id_);
|
|
}
|
|
|
|
bool MpvPlayer::ProcessEvents() {
|
|
if (disposed_ || !mpv_) return false;
|
|
|
|
while (true) {
|
|
mpv_event* event = mpv_wait_event(mpv_, 0);
|
|
if (event->event_id == MPV_EVENT_NONE) {
|
|
break;
|
|
}
|
|
if (event->event_id == MPV_EVENT_SHUTDOWN) {
|
|
return false;
|
|
}
|
|
HandleMpvEvent(event);
|
|
}
|
|
return true;
|
|
}
|
|
|
|
void MpvPlayer::LogRecovery(const std::string& text) {
|
|
g_warning("MPV audio-recovery: %s", text.c_str());
|
|
FlValue* data = fl_value_new_map();
|
|
fl_value_set_string_take(data, "prefix", fl_value_new_string("audio-recovery"));
|
|
fl_value_set_string_take(data, "level", fl_value_new_string("warn"));
|
|
fl_value_set_string_take(data, "text", fl_value_new_string(text.c_str()));
|
|
SendEvent("log-message", data);
|
|
fl_value_unref(data);
|
|
}
|
|
|
|
void MpvPlayer::TryAudioReload(const char* reason, int attempt, uint64_t request_generation) {
|
|
LogRecovery("issuing ao-reload (reason=" + std::string(reason) + ", attempt " + std::to_string(attempt) + ")");
|
|
const std::string reason_copy = reason;
|
|
auto callback_context = callback_context_;
|
|
CommandAsync({"ao-reload"}, [callback_context, reason_copy, attempt, request_generation](int error) {
|
|
auto lease = callback_context->Acquire();
|
|
if (!lease) return;
|
|
MpvPlayer* player = lease.player();
|
|
player->audio_recovery_.CompleteReload(request_generation);
|
|
player->LogRecovery(
|
|
"ao-reload completed (reason=" + reason_copy + ", attempt " + std::to_string(attempt) +
|
|
", error=" + std::to_string(error) + ")");
|
|
});
|
|
}
|
|
|
|
void MpvPlayer::MaybeRunAudioRecovery() {
|
|
const auto action = audio_recovery_.NextReload(plezy::mpv_common::AudioRecoveryState::Clock::now());
|
|
if (action.reason == plezy::mpv_common::AudioReloadReason::kNone) {
|
|
return;
|
|
}
|
|
const char* reason = action.reason == plezy::mpv_common::AudioReloadReason::kResume ? "resume" : "null-fallback";
|
|
TryAudioReload(reason, action.attempt, action.request_generation);
|
|
if (action.exhausted) {
|
|
LogRecovery("audio recovery budget exhausted; waiting for device list change");
|
|
}
|
|
}
|
|
|
|
void MpvPlayer::EnsureAudioRecoveryTimer() {
|
|
if (!audio_recovery_.HasPendingWork()) return;
|
|
ScheduleRecoverySource();
|
|
}
|
|
|
|
void MpvPlayer::HandleMpvEvent(mpv_event* event) {
|
|
if (plezy::mpv_common::DispatchReplyEvent(
|
|
pending_requests_, event, [](const char* value) { return SanitizeUtf8(value); })) {
|
|
return;
|
|
}
|
|
|
|
switch (event->event_id) {
|
|
case MPV_EVENT_LOG_MESSAGE: {
|
|
auto* msg = static_cast<mpv_event_log_message*>(event->data);
|
|
if (!msg) break;
|
|
g_message("MPV [%s] %s: %s", msg->level, msg->prefix, msg->text);
|
|
|
|
FlValue* data = fl_value_new_map();
|
|
fl_value_set_string_take(data, "prefix", fl_value_new_string(SanitizeUtf8(msg->prefix).c_str()));
|
|
fl_value_set_string_take(data, "level", fl_value_new_string(SanitizeUtf8(msg->level).c_str()));
|
|
fl_value_set_string_take(data, "text", fl_value_new_string(SanitizeUtf8(msg->text).c_str()));
|
|
SendEvent("log-message", data);
|
|
fl_value_unref(data);
|
|
break;
|
|
}
|
|
case MPV_EVENT_PROPERTY_CHANGE: {
|
|
auto* prop = static_cast<mpv_event_property*>(event->data);
|
|
if (!prop || !prop->name) break;
|
|
mpv_node node = plezy::mpv_common::ExtractPropertyNode(prop);
|
|
|
|
// This runner's own observation, which nothing on the Dart side asked
|
|
// for and nothing there is waiting on. mpv delivers one event per
|
|
// observation, so a Dart-side observer of the same property still gets
|
|
// its own under its own userdata.
|
|
if (event->reply_userdata == kVideoParamsUserdata) {
|
|
UpdateSourceHdrMetadata(&node);
|
|
break;
|
|
}
|
|
if (event->reply_userdata == kHwdecCurrentUserdata) {
|
|
const char* value = node.format == MPV_FORMAT_STRING ? node.u.string : nullptr;
|
|
g_message("MPV: hwdec-current=%s", value && value[0] != '\0' ? value : "(none)");
|
|
break;
|
|
}
|
|
|
|
const auto notice = plezy::mpv_common::ObserveAudioRecoveryProperty(audio_recovery_, event, prop);
|
|
if (notice.message) LogRecovery(notice.message);
|
|
// Recovery runs off a GLib timer here, so newly queued work has to arm it.
|
|
if (notice.scheduled_work) EnsureAudioRecoveryTimer();
|
|
|
|
SendPropertyChange(prop->name, &node);
|
|
break;
|
|
}
|
|
case MPV_EVENT_END_FILE: {
|
|
audio_recovery_.SetFileLoaded(false);
|
|
// Whatever comes next is a different source until video-params says
|
|
// otherwise, and describing it against this one's colour space is the
|
|
// one failure worth a transient wrong answer to avoid. No re-apply is
|
|
// requested: the plane keeps the description it has until the next
|
|
// playback-restart or video-params change, exactly as before.
|
|
{
|
|
std::lock_guard<std::mutex> lock(native_mutex_);
|
|
source_hdr_metadata_ = SourceHdrMetadata();
|
|
}
|
|
auto* end = static_cast<mpv_event_end_file*>(event->data);
|
|
if (!end) break;
|
|
FlValue* data = fl_value_new_map();
|
|
fl_value_set_string_take(data, "reason", fl_value_new_int(static_cast<int>(end->reason)));
|
|
if (end->reason == MPV_END_FILE_REASON_ERROR) {
|
|
fl_value_set_string_take(data, "error", fl_value_new_int(static_cast<int>(end->error)));
|
|
fl_value_set_string_take(
|
|
data, "message", fl_value_new_string(SanitizeUtf8(mpv_error_string(end->error)).c_str()));
|
|
}
|
|
SendEvent("end-file", data);
|
|
fl_value_unref(data);
|
|
break;
|
|
}
|
|
case MPV_EVENT_START_FILE: {
|
|
SendEvent("start-file");
|
|
break;
|
|
}
|
|
case MPV_EVENT_FILE_LOADED: {
|
|
audio_recovery_.SetFileLoaded(true);
|
|
EnsureAudioRecoveryTimer();
|
|
SendEvent("file-loaded");
|
|
break;
|
|
}
|
|
case MPV_EVENT_PLAYBACK_RESTART: {
|
|
SendEvent("playback-restart");
|
|
break;
|
|
}
|
|
default:
|
|
break;
|
|
}
|
|
}
|
|
|
|
namespace {
|
|
|
|
// Adapts the shared, bounded mpv_node walk onto GLib-owned FlValues.
|
|
struct FlValueNodeBuilder {
|
|
using Value = FlValue*;
|
|
using ListBuilder = FlValue*;
|
|
using MapBuilder = FlValue*;
|
|
|
|
static Value Null() { return fl_value_new_null(); }
|
|
static Value Boolean(bool value) { return fl_value_new_bool(value); }
|
|
static Value Int(int64_t value) { return fl_value_new_int(value); }
|
|
static Value Double(double value) { return fl_value_new_float(value); }
|
|
static Value String(const char* value, size_t length) {
|
|
return fl_value_new_string(SanitizeUtf8(value, length).c_str());
|
|
}
|
|
|
|
static ListBuilder NewList() { return fl_value_new_list(); }
|
|
static void Append(ListBuilder& list, Value value) { fl_value_append_take(list, value); }
|
|
static Value FinishList(ListBuilder list) { return list; }
|
|
|
|
static MapBuilder NewMap() { return fl_value_new_map(); }
|
|
static void Insert(MapBuilder& map, const char* key, size_t key_length, Value value) {
|
|
fl_value_set_string_take(map, SanitizeUtf8(key, key_length).c_str(), value);
|
|
}
|
|
static Value FinishMap(MapBuilder map) { return map; }
|
|
static void AbandonMap(MapBuilder& map) { fl_value_unref(map); }
|
|
};
|
|
|
|
} // namespace
|
|
|
|
FlValue* MpvPlayer::NodeToFlValue(mpv_node* node) { return plezy::mpv_common::ConvertNode<FlValueNodeBuilder>(node); }
|
|
|
|
FlValue* MpvPlayer::NodeToFlValue(mpv_node* node, plezy::mpv_common::NodeConversionBudget* budget) {
|
|
return plezy::mpv_common::ConvertNode<FlValueNodeBuilder>(node, 0, budget);
|
|
}
|
|
|
|
void MpvPlayer::SendPropertyChange(const char* name, mpv_node* data) {
|
|
if (!name) return;
|
|
|
|
int id = 0;
|
|
if (!observed_properties_.LookupId(name, &id)) return;
|
|
|
|
FlValue* list = fl_value_new_list();
|
|
fl_value_append_take(list, fl_value_new_int(id));
|
|
if (data) {
|
|
fl_value_append_take(list, NodeToFlValue(data));
|
|
} else {
|
|
fl_value_append_take(list, fl_value_new_null());
|
|
}
|
|
|
|
EventCallback callback;
|
|
{
|
|
std::lock_guard<std::mutex> lock(callback_mutex_);
|
|
callback = event_callback_;
|
|
}
|
|
if (callback) callback(list);
|
|
fl_value_unref(list);
|
|
}
|
|
|
|
void MpvPlayer::SendEvent(const std::string& name, FlValue* data) {
|
|
FlValue* event_map = fl_value_new_map();
|
|
fl_value_set_string_take(event_map, "type", fl_value_new_string("event"));
|
|
fl_value_set_string_take(event_map, "name", fl_value_new_string(name.c_str()));
|
|
if (data) {
|
|
fl_value_set_string_take(event_map, "data", fl_value_ref(data));
|
|
}
|
|
|
|
EventCallback callback;
|
|
{
|
|
std::lock_guard<std::mutex> lock(callback_mutex_);
|
|
callback = event_callback_;
|
|
}
|
|
if (callback) callback(event_map);
|
|
fl_value_unref(event_map);
|
|
}
|
|
|
|
void MpvPlayer::ApplyPropertySequence(
|
|
std::shared_ptr<std::vector<PropertyChange>> changes, size_t index, StatusCallback callback) {
|
|
if (changes == nullptr || index >= changes->size()) {
|
|
if (callback) callback(MPV_ERROR_SUCCESS);
|
|
return;
|
|
}
|
|
const PropertyChange& change = (*changes)[index];
|
|
SetPropertyAsync(change.name, change.value, [this, changes, index, cb = std::move(callback)](int error) mutable {
|
|
if (plezy::mpv_common::SetPropertyStatusSucceeded(error)) {
|
|
ApplyPropertySequence(changes, index + 1, std::move(cb));
|
|
return;
|
|
}
|
|
// `index` entries already landed and must come back, newest
|
|
// first, so a refused change leaves the previous state intact
|
|
// rather than a half-applied mixture of the two.
|
|
RollbackPropertySequence(changes, index, error, std::move(cb));
|
|
});
|
|
}
|
|
|
|
void MpvPlayer::RollbackPropertySequence(
|
|
std::shared_ptr<std::vector<PropertyChange>> changes, size_t undo_count, int failure, StatusCallback callback) {
|
|
if (changes == nullptr || undo_count == 0) {
|
|
// Only now is mpv genuinely back where it started, so only now may the caller
|
|
// hear about it. The original failure is what it needs, not the outcome of the
|
|
// unwinding.
|
|
if (callback) callback(failure);
|
|
return;
|
|
}
|
|
const size_t index = undo_count - 1;
|
|
const PropertyChange& change = (*changes)[index];
|
|
SetPropertyAsync(
|
|
change.name, change.rollback, [this, changes, index, failure, cb = std::move(callback)](int error) mutable {
|
|
if (plezy::mpv_common::SetPropertyStatusSucceeded(error)) {
|
|
RollbackPropertySequence(changes, index, failure, std::move(cb));
|
|
return;
|
|
}
|
|
// Carrying on would leave mpv in a state that is neither the
|
|
// old one nor the new, and *no* surface description is correct
|
|
// for a signal nobody can name. Escalate to the one state that
|
|
// is always describable and always accepts its value: SDR.
|
|
g_warning(
|
|
"MPV: could not restore %s while unwinding a refused output colour space; "
|
|
"forcing SDR",
|
|
(*changes)[index].name.c_str());
|
|
ForceSdrOutput(0, failure, std::move(cb));
|
|
});
|
|
}
|
|
|
|
// The one place the applied-output cache is written, so every path that moves a
|
|
// property records it the same way. Matched by name, not position: the order the
|
|
// sequences use is load-bearing and has to stay free to change without silently
|
|
// reassigning the wrong field.
|
|
void MpvPlayer::RecordAppliedOutputProperty(const std::string& name, const std::string& value) {
|
|
if (name == "target-peak") {
|
|
applied_target_peak_ = value;
|
|
} else if (name == "target-prim") {
|
|
applied_target_prim_ = value;
|
|
} else if (name == "target-trc") {
|
|
applied_target_trc_ = value;
|
|
} else if (name == "tone-mapping") {
|
|
applied_tone_mapping_ = value;
|
|
}
|
|
}
|
|
|
|
void MpvPlayer::ForceSdrOutput(size_t index, int failure, StatusCallback callback) {
|
|
// Same order the apply path uses: the transfer function stops asking for HDR
|
|
// before the primaries, operator and peak follow it back.
|
|
static const char* const kResetOrder[] = {"target-trc", "target-prim", "tone-mapping", "target-peak"};
|
|
constexpr size_t kResetCount = sizeof(kResetOrder) / sizeof(kResetOrder[0]);
|
|
if (index >= kResetCount) {
|
|
// mpv is SDR now, not back where it started, so any HDR description the
|
|
// caller has already committed is a lie about these pixels.
|
|
hdr_unwind_result_ = HdrOutputResult::kForcedSdr;
|
|
output_state_known_ = true;
|
|
if (callback) callback(failure);
|
|
return;
|
|
}
|
|
SetPropertyAsync(kResetOrder[index], "auto", [this, index, failure, cb = std::move(callback)](int error) mutable {
|
|
if (plezy::mpv_common::SetPropertyStatusSucceeded(error)) {
|
|
// Recorded as it lands, not once the whole reset is through. Recording
|
|
// only at the end would leave the cache naming the pre-reset curve for
|
|
// every property that did move if a later one is refused, and the no-op
|
|
// short-circuit would then answer a repeat request from it - committing an
|
|
// HDR description over pixels mpv had already reset to SDR.
|
|
RecordAppliedOutputProperty(kResetOrder[index], "auto");
|
|
ForceSdrOutput(index + 1, failure, std::move(cb));
|
|
return;
|
|
}
|
|
// `auto` is valid for every one of them, so this failing means mpv is
|
|
// no longer taking orders at all - usually because it is being
|
|
// disposed. Either way what it emits is now unknowable, and the
|
|
// caller must stop presenting the plane rather than guess.
|
|
hdr_unwind_result_ = HdrOutputResult::kUnknown;
|
|
// And the cache is now a record of what we *asked* for, not what mpv holds:
|
|
// some of the reset landed and some did not. Marking it untrusted is what
|
|
// stops the short-circuit skipping a later write on the strength of it. The
|
|
// strings are left alone deliberately - they are still the best rollback
|
|
// targets available if a later sequence gets that far.
|
|
output_state_known_ = false;
|
|
g_warning(
|
|
"MPV: output colour space is no longer commandable; what the plane emits "
|
|
"is unknown");
|
|
if (cb) cb(failure);
|
|
});
|
|
}
|
|
|
|
bool MpvPlayer::CanCommandOutputProperties() const {
|
|
#ifdef PLEZY_MPV_PLAYER_LIFECYCLE_TEST
|
|
// The substituted writer is the core here; see SetPropertyAsync, which routes
|
|
// to it ahead of the same handle check.
|
|
if (test_property_write_) return true;
|
|
#endif
|
|
return !disposed_ && mpv_ != nullptr;
|
|
}
|
|
|
|
#ifdef PLEZY_MPV_PLAYER_LIFECYCLE_TEST
|
|
void MpvPlayer::ConfigurePropertyWritesForTesting(PropertyWriteForTesting writer) {
|
|
test_property_write_ = std::move(writer);
|
|
}
|
|
|
|
MpvPlayer::AppliedOutputColourSpace MpvPlayer::AppliedOutputColourSpaceForTesting() const {
|
|
return {applied_target_trc_, applied_target_prim_, applied_tone_mapping_, applied_target_peak_};
|
|
}
|
|
#endif
|
|
|
|
void MpvPlayer::SetHdrOutput(SourceTransfer transfer, uint32_t target_peak_nits, HdrOutputCallback callback) {
|
|
if (!CanCommandOutputProperties()) {
|
|
// The third place a result is named, and it owes the same honesty as the
|
|
// other two: nothing was touched, so the previous state stands - which is
|
|
// only worth saying when that state is nameable. Otherwise a request that
|
|
// was already queued when the core went away is answered kUnknown by the
|
|
// drain while an identical one arriving a moment later hears kRestored.
|
|
if (callback) {
|
|
callback(output_state_known_ ? HdrOutputResult::kRestored : HdrOutputResult::kUnknown, MPV_ERROR_UNINITIALIZED);
|
|
}
|
|
return;
|
|
}
|
|
// Requests are serialized, and queued rather than coalesced.
|
|
//
|
|
// Playback restarts, preferred-description changes and the two settings can
|
|
// each ask for a new output colour space, and every step of a sequence
|
|
// completes asynchronously. Two overlapping sequences would interleave: a
|
|
// failure in the older one would issue rollbacks that overwrite properties the
|
|
// newer one had already set, while the newer one still reported success and
|
|
// recorded values mpv no longer holds. That is the divergence the sequencing
|
|
// exists to prevent.
|
|
//
|
|
// Each request keeps its own callback instead of being folded into the newest
|
|
// one, because callers commit their own state on success: telling a caller its
|
|
// change landed when a *different* request is what actually landed reintroduces
|
|
// the same divergence one level up. Strict ordering then makes the bookkeeping
|
|
// trivial — the last request to succeed is exactly what mpv holds, so nobody
|
|
// needs an epoch to work out whether their commit is still current.
|
|
hdr_queue_.push_back(HdrOutputRequest{transfer, target_peak_nits, std::move(callback)});
|
|
if (hdr_sequence_in_flight_) return;
|
|
RunPendingHdrOutput();
|
|
}
|
|
|
|
void MpvPlayer::RunPendingHdrOutput() {
|
|
if (!CanCommandOutputProperties()) {
|
|
hdr_sequence_in_flight_ = false;
|
|
auto orphaned = std::move(hdr_queue_);
|
|
hdr_queue_.clear();
|
|
for (auto& request : orphaned) {
|
|
// Nothing was touched, so the previous state - whatever it was - still
|
|
// stands as far as this request is concerned. Which is only worth telling
|
|
// the caller when that state is nameable; if the last unwind gave up
|
|
// halfway, "unchanged" describes a colour space nobody knows.
|
|
if (request.callback) {
|
|
request.callback(
|
|
output_state_known_ ? HdrOutputResult::kRestored : HdrOutputResult::kUnknown, MPV_ERROR_UNINITIALIZED);
|
|
}
|
|
}
|
|
return;
|
|
}
|
|
if (hdr_queue_.empty()) {
|
|
hdr_sequence_in_flight_ = false;
|
|
return;
|
|
}
|
|
HdrOutputRequest request = std::move(hdr_queue_.front());
|
|
hdr_queue_.pop_front();
|
|
hdr_sequence_in_flight_ = true;
|
|
// Assume a clean unwind; the escalation path in RollbackPropertySequence and
|
|
// ForceSdrOutput moves this on if it cannot manage one.
|
|
hdr_unwind_result_ = HdrOutputResult::kRestored;
|
|
|
|
// Four properties describe one output colour space, so they are applied as a
|
|
// unit. A plane whose primaries moved to BT.2020 while its transfer function
|
|
// stayed on gamma is neither SDR nor HDR, and the caller describes the surface
|
|
// to the compositor on success — a silently half-applied set would have the
|
|
// compositor told one thing and shown another.
|
|
//
|
|
// target-peak is what mpv maps to. Under PQ, left on auto it resolves to the
|
|
// format's nominal 10000 nits, so the renderer never tone-maps and the
|
|
// compositor owns the decision. Set to the display's real peak, mpv tone-maps
|
|
// to it and the caller declares that same peak, leaving the compositor nothing
|
|
// to do.
|
|
//
|
|
// On the SDR fallback the peak and curve are named for accuracy, not to make
|
|
// tone mapping happen: mpv 0.40 already resolves target-peak=auto to 203 nits
|
|
// and target-trc=auto to gamma 2.2 for an SDR curve, and measurement confirmed
|
|
// naming them changed the shadows but not the highlights. What they buy is the
|
|
// surface's real terms instead of assumed ones - the compositor's own reference
|
|
// white, and sRGB, which is what an undescribed Wayland surface is and what
|
|
// this compositor's preferred description for the output says. Hence no
|
|
// `enabled` in the peak condition below: an SDR peak is a real instruction, not
|
|
// a leftover from an HDR request.
|
|
const bool enabled = request.transfer != SourceTransfer::kSdr;
|
|
const char* primaries = enabled ? "bt.2020" : "auto";
|
|
// The option is an integer in [10, 10000]; anything outside means "auto".
|
|
const bool tone_map_here = request.peak_nits >= 10 && request.peak_nits <= kPqMaxLuminanceNits;
|
|
const std::string peak = tone_map_here ? std::to_string(request.peak_nits) : std::string("auto");
|
|
const char* curve = request.transfer == SourceTransfer::kHlg
|
|
? "hlg"
|
|
: (request.transfer == SourceTransfer::kPq ? "pq" : (tone_map_here ? "srgb" : "auto"));
|
|
|
|
// The operator only matters while a tone-map pass runs, and it must go back to
|
|
// auto when one does not; see applied_tone_mapping_ in mpv_player.h.
|
|
//
|
|
// Restricted to the undescribed SDR target, which is where it was measured.
|
|
// Player-side mapping onto an HDR output aims at a PQ target instead, and
|
|
// nothing has been measured there yet - that needs the external display - so
|
|
// it keeps mpv's own choice until it can be judged the same way.
|
|
//
|
|
// mobius's shape is governed by tone-mapping-param, its transition point: below
|
|
// it the curve is 1:1, above it rolls off. Left at mpv's default 0.3 because
|
|
// that measured best, not by omission. Raising it trades highlight shoulder for
|
|
// in-range luminance, and against libplacebo's rendering of the same chart
|
|
// (400/700/1000 -> 238.5/253.8/254.8, 100 nits -> 134.0) the default is closest
|
|
// on both counts, with higher values moving away on each:
|
|
//
|
|
// param 100 nits 400->1000 span
|
|
// 0.30 179.0 17.1
|
|
// 0.45 184.4 12.1
|
|
// 0.60 185.0 7.2
|
|
//
|
|
// It also does not touch the cost this operator carries. On real 1000-nit
|
|
// footage mobius sits 0.027 dxy and ~12% darker than BT.2390 whatever the
|
|
// transition point is (0.30/0.38/0.45 measured identical), because that
|
|
// difference is gamut handling rather than the tone curve, and a dark scene's
|
|
// pixels fall below the transition point in every case.
|
|
//
|
|
// Not pinned explicitly: the option has no accepted "unset" token - `default`
|
|
// is rejected - so writing it would leave a mobius-specific value applied to
|
|
// whatever operator runs next, including BT.2390 on the unmeasured HDR-output
|
|
// path. Recorded here instead so an upstream default change is diagnosable.
|
|
const char* operator_name = (tone_map_here && !enabled) ? "mobius" : "auto";
|
|
|
|
// playback-restart drives a re-apply and fires on every seek, so most calls
|
|
// here ask for the state mpv already holds. The plane's own half already
|
|
// short-circuits an identical request; this is the other half. Without it a
|
|
// seek costs four property round-trips and a log line saying nothing changed,
|
|
// and holds the sequence long enough to defer a real request behind it.
|
|
//
|
|
// kApplied, because that is the truth the caller acts on: these four values
|
|
// *are* in force, so a surface description committed against them stays
|
|
// honest. The queue has to keep draining from here exactly as it does on the
|
|
// applied path, or a coalesced request behind this one never runs. Unlike that
|
|
// path the call is a real recursion rather than a fresh stack, which is fine
|
|
// because the plugin coalesces reapplies into a single pending flag: the queue
|
|
// holds the one in flight plus at most one waiting.
|
|
// output_state_known_ first: the comparison is only meaningful while the cache
|
|
// is a record of what mpv holds. A forced-SDR reset that was itself refused
|
|
// partway leaves it a record of what was *asked* for, and skipping on that
|
|
// would report kApplied for a colour space mpv is not in - which the caller
|
|
// then commits an image description against.
|
|
if (output_state_known_ && applied_target_trc_ == curve && applied_target_prim_ == primaries &&
|
|
applied_tone_mapping_ == operator_name && applied_target_peak_ == peak) {
|
|
if (request.callback) request.callback(HdrOutputResult::kApplied, 0);
|
|
RunPendingHdrOutput();
|
|
return;
|
|
}
|
|
|
|
// The values that decide who tone-maps and against what, none of which is
|
|
// visible on screen: two very different curves both look like working video.
|
|
// Logged next to the plane's own decisions so a capture can be matched to the
|
|
// state that produced it.
|
|
g_message(
|
|
"MPV: output colour target peak=%s prim=%s trc=%s tone-mapping=%s", peak.c_str(), primaries, curve,
|
|
operator_name);
|
|
|
|
// Dependencies first, peak last, matching the order kResetOrder uses. The peak
|
|
// is what decides whether a tone-map pass runs at all, so everything that pass
|
|
// depends on is in place before it is named.
|
|
auto changes = std::make_shared<std::vector<PropertyChange>>();
|
|
changes->push_back({"target-trc", curve, applied_target_trc_});
|
|
changes->push_back({"target-prim", primaries, applied_target_prim_});
|
|
changes->push_back({"tone-mapping", operator_name, applied_tone_mapping_});
|
|
changes->push_back({"target-peak", peak, applied_target_peak_});
|
|
ApplyPropertySequence(changes, 0, [this, changes, callback = std::move(request.callback)](int error) {
|
|
const bool ok = plezy::mpv_common::SetPropertyStatusSucceeded(error);
|
|
// Only a fully applied set becomes the new rollback target; a failed one was
|
|
// already unwound, and the unwinding updated these itself if it had to force
|
|
// SDR.
|
|
if (ok && !disposed_) {
|
|
for (const PropertyChange& change : *changes) {
|
|
RecordAppliedOutputProperty(change.name, change.value);
|
|
}
|
|
// A clean apply is the one outcome that leaves mpv exactly where the cache
|
|
// says, so it is what re-earns the short-circuit's trust after an unwind
|
|
// gave up halfway.
|
|
output_state_known_ = true;
|
|
}
|
|
// This request's own outcome, to this request's own caller. The result names
|
|
// what mpv is actually in now, which is what decides whether the caller's
|
|
// committed surface description is still true.
|
|
//
|
|
// kRestored says "mpv is where it was", which only reassures the caller while
|
|
// where it was is known. After an unwind that gave up halfway it is not: a
|
|
// sequence refused on its very first write unwinds nothing, so it reports the
|
|
// untouched kRestored while mpv sits in the half-reset state nobody can name.
|
|
// The caller would read that as "your description still holds" and put an
|
|
// undescribed plane back on screen. Downgrading to kUnknown is the honest
|
|
// answer, and a clean apply - the one thing that re-earns the trust - is
|
|
// reported as kApplied above regardless.
|
|
const HdrOutputResult result =
|
|
ok ? HdrOutputResult::kApplied : (output_state_known_ ? hdr_unwind_result_ : HdrOutputResult::kUnknown);
|
|
if (callback) callback(result, error);
|
|
// Whatever arrived while this ran runs now — never alongside.
|
|
RunPendingHdrOutput();
|
|
});
|
|
}
|
|
|
|
void MpvPlayer::SetHDREnabled(bool enabled, StatusCallback callback) {
|
|
SetPropertyAsync(
|
|
"target-colorspace-hint", plezy::mpv_common::TargetColorspaceHint(enabled),
|
|
[this, enabled, callback = std::move(callback)](int error) mutable {
|
|
if (plezy::mpv_common::SetPropertyStatusSucceeded(error) && !disposed_) {
|
|
hdr_enabled_ = enabled;
|
|
}
|
|
if (callback) callback(error);
|
|
});
|
|
}
|
|
} // namespace mpv
|