DR-231 works. Video and audio, drawn by mpv into a framebuffer we own and
blitted into the default vbox's draw handler with `gdk_cairo_draw_from_gl()`.
The widget tree Tauri built is untouched, so nothing here can be invalidated by
a Tauri upgrade that assumes its own layout.
That settles finding 2 of playback-backend-unification.md on Linux by
demonstration rather than argument, and completes the half of G1 the spike could
not test.
Three pieces had to land together, none of which existed before:
- mpv was configured with `video: no` and no video output, so it had never
decoded a frame in this app. Now `vo=libmpv` when native video is on.
- `player_play_item` skipped loading into the native backend on Linux behind a
`#[cfg(not(target_os = "linux"))]`, because the webview always played video
there. With the webview no longer loading it, that guard meant *nothing*
played — no picture and no audio, which reads as a broken stream rather than
as a file nobody was given.
- The webview paints its own opaque background. Android clears it through a
Kotlin bridge from `enableNativeVideoCompositing()`; the CSS half of that
already ran on Linux, so only `transparent: true` on the window was missing.
Until it was, the frame was rendered correctly and covered by white.
Frame pacing is polled, not pushed. The tick callback asks mpv `has_frame()` and
draws only when the answer is yes. Both neighbouring designs were tried and both
fail, in ways that point at the wrong culprit:
- Waiting on mpv's update callback before rendering *deadlocks*: mpv does not
progress until the client renders, so if the client waits to be told, the
two hold each other. The file loads, one frame appears, and everything
stops.
- Rendering every frame-clock tick and reporting a swap each time claims a
presentation far more often than one happened. It plays, and judders badly —
which reads as a GPU or compositing limit, exactly as the spike warned.
The update callback survives as a hint and does the least it safely can from an
mpv thread: set an `AtomicBool`. It must not touch GTK — `idle_add_local*`
requires the caller to own the main context and panics from there — and it must
not hold the `Rc<RefCell<..>>` state, which is not `Send`.
Two memory-safety fixes in this file's own short history, both worth recording
because neither announced itself:
- The callback context was handed over with `Rc::into_raw` (a pointer to the
Rc's *contents*) and read back as `*const Rc<..>`, reinterpreting a RefCell
as an Rc and corrupting its refcount on the first clone. mpv invokes the
callback immediately, so this happened before anything drew. The symptom was
the process ending quietly with status 0.
- The surface was attached before the player backend was constructed, so the
mpv handle it needs had not been registered yet and it found null every
time.
Teardown (DR-232) is confirmed working on a real run: callback unregistered,
render context freed, GL objects released with the context still current, boxed
callback state reclaimed only after mpv can no longer reach it — no crash.
Still behind JELLYTAU_NATIVE_VIDEO=1 and off by default. Known open: whether
exiting the player stops mpv (reported, evidence ambiguous, needs re-checking
now the picture works), hardware decode (DR-236), and deleting the webview video
path (DR-235).
406 lines
15 KiB
Rust
406 lines
15 KiB
Rust
//! mpv's render API, driven into an OpenGL framebuffer we own.
|
|
//!
|
|
//! This is the half of native video that is not GTK: create a render context
|
|
//! over the mpv handle the audio backend already drives, render a frame into a
|
|
//! texture, and hand that texture id back for the toolkit to composite.
|
|
//!
|
|
//! Kept apart from `video_surface` deliberately — everything here is portable
|
|
//! across the platforms this app targets, while the surface that consumes it is
|
|
//! not. Windows reuses this file unchanged (DR-237).
|
|
//!
|
|
//! TRACES: UR-080 | DR-231, DR-232, IR-033
|
|
|
|
use std::ffi::{c_void, CStr, CString};
|
|
use std::os::raw::{c_char, c_int};
|
|
use std::ptr;
|
|
|
|
use log::{error, info, warn};
|
|
|
|
/// GL entry points, resolved once.
|
|
///
|
|
/// Only the handful needed to own a framebuffer; mpv resolves everything else
|
|
/// it needs through [`get_proc_address`].
|
|
struct Gl {
|
|
gen_framebuffers: unsafe extern "C" fn(c_int, *mut u32),
|
|
delete_framebuffers: unsafe extern "C" fn(c_int, *const u32),
|
|
bind_framebuffer: unsafe extern "C" fn(u32, u32),
|
|
framebuffer_texture_2d: unsafe extern "C" fn(u32, u32, u32, u32, c_int),
|
|
gen_textures: unsafe extern "C" fn(c_int, *mut u32),
|
|
delete_textures: unsafe extern "C" fn(c_int, *const u32),
|
|
bind_texture: unsafe extern "C" fn(u32, u32),
|
|
tex_image_2d:
|
|
unsafe extern "C" fn(u32, c_int, c_int, c_int, c_int, c_int, u32, u32, *const c_void),
|
|
tex_parameteri: unsafe extern "C" fn(u32, u32, c_int),
|
|
check_framebuffer_status: unsafe extern "C" fn(u32) -> u32,
|
|
}
|
|
|
|
const GL_TEXTURE_2D: u32 = 0x0DE1;
|
|
const GL_FRAMEBUFFER: u32 = 0x8D40;
|
|
const GL_COLOR_ATTACHMENT0: u32 = 0x8CE0;
|
|
const GL_RGBA: u32 = 0x1908;
|
|
const GL_RGBA8: c_int = 0x8058;
|
|
const GL_UNSIGNED_BYTE: u32 = 0x1401;
|
|
const GL_LINEAR: c_int = 0x2601;
|
|
const GL_TEXTURE_MIN_FILTER: u32 = 0x2801;
|
|
const GL_TEXTURE_MAG_FILTER: u32 = 0x2800;
|
|
const GL_FRAMEBUFFER_COMPLETE: u32 = 0x8CD5;
|
|
|
|
/// Resolve a GL symbol the way libepoxy actually exports it.
|
|
///
|
|
/// **This is the trap that cost the spike a debugging cycle.** libepoxy does not
|
|
/// export `glFoo` as a function. It exports `epoxy_glFoo` as a *data* symbol
|
|
/// holding a lazily-resolving function pointer. So the address `dlsym` returns
|
|
/// is the address *of the pointer*, not of any code: returning it makes mpv jump
|
|
/// into non-executable data and take SIGSEGV/SEGV_ACCERR on the very first GL
|
|
/// call. The value must be read *out of* that location.
|
|
///
|
|
/// The `epoxy` crate does this correctly and is unusable here — its
|
|
/// `gl_generator` dependency pulls a yanked `xml-rs`.
|
|
///
|
|
/// TRACES: UR-080 | IR-033
|
|
unsafe fn resolve(name: &str) -> *mut c_void {
|
|
let epoxy_name = match CString::new(format!("epoxy_{name}")) {
|
|
Ok(n) => n,
|
|
Err(_) => return ptr::null_mut(),
|
|
};
|
|
let slot = libc::dlsym(libc::RTLD_DEFAULT, epoxy_name.as_ptr());
|
|
if !slot.is_null() {
|
|
// The symbol holds the function pointer; return what is stored there.
|
|
return *(slot as *mut *mut c_void);
|
|
}
|
|
|
|
// Fall back to a plain symbol, for a GL stack that is not behind epoxy.
|
|
match CString::new(name) {
|
|
Ok(n) => libc::dlsym(libc::RTLD_DEFAULT, n.as_ptr()),
|
|
Err(_) => ptr::null_mut(),
|
|
}
|
|
}
|
|
|
|
/// What mpv calls to find GL entry points. Same rule as [`resolve`].
|
|
unsafe extern "C" fn get_proc_address(_ctx: *mut c_void, name: *const c_char) -> *mut c_void {
|
|
if name.is_null() {
|
|
return ptr::null_mut();
|
|
}
|
|
match CStr::from_ptr(name).to_str() {
|
|
Ok(n) => resolve(n),
|
|
Err(_) => ptr::null_mut(),
|
|
}
|
|
}
|
|
|
|
macro_rules! load {
|
|
($name:literal) => {{
|
|
let p = resolve($name);
|
|
if p.is_null() {
|
|
error!("[MpvRender] GL symbol not found: {}", $name);
|
|
return None;
|
|
}
|
|
std::mem::transmute(p)
|
|
}};
|
|
}
|
|
|
|
impl Gl {
|
|
/// Resolve every entry point, or none — a partially-loaded table would fail
|
|
/// later at a call site with no context.
|
|
///
|
|
/// The transmutes are unannotated on purpose: each target type is declared
|
|
/// once on the struct field above, and repeating it at the call site would
|
|
/// be two places to get the same signature wrong.
|
|
#[allow(clippy::missing_transmute_annotations)]
|
|
unsafe fn load() -> Option<Self> {
|
|
Some(Gl {
|
|
gen_framebuffers: load!("glGenFramebuffers"),
|
|
delete_framebuffers: load!("glDeleteFramebuffers"),
|
|
bind_framebuffer: load!("glBindFramebuffer"),
|
|
framebuffer_texture_2d: load!("glFramebufferTexture2D"),
|
|
gen_textures: load!("glGenTextures"),
|
|
delete_textures: load!("glDeleteTextures"),
|
|
bind_texture: load!("glBindTexture"),
|
|
tex_image_2d: load!("glTexImage2D"),
|
|
tex_parameteri: load!("glTexParameteri"),
|
|
check_framebuffer_status: load!("glCheckFramebufferStatus"),
|
|
})
|
|
}
|
|
}
|
|
|
|
/// A colour-renderable framebuffer mpv draws into, sized to the widget.
|
|
struct Target {
|
|
fbo: u32,
|
|
texture: u32,
|
|
width: i32,
|
|
height: i32,
|
|
}
|
|
|
|
/// mpv's render context plus the framebuffer it draws into.
|
|
///
|
|
/// # Lifetime (DR-232)
|
|
///
|
|
/// The render context must not outlive the GL context it was created against.
|
|
/// `Drop` unregisters mpv's update callback *before* freeing the context, so a
|
|
/// callback cannot land on a freed pointer, and frees the GL objects while the
|
|
/// caller still has the context current. The caller is responsible for making
|
|
/// the GL context current around both creation and drop — see `video_surface`.
|
|
///
|
|
/// This is DR-184 on Android restated: a surface outliving its player. The spike
|
|
/// had no defence at all and saw one unexplained SIGSEGV in a decoder thread.
|
|
pub struct MpvRenderContext {
|
|
ctx: *mut libmpv_sys::mpv_render_context,
|
|
gl: Gl,
|
|
target: Option<Target>,
|
|
}
|
|
|
|
// The render context is driven only from the GTK main thread; the update
|
|
// callback merely schedules a redraw and touches nothing here.
|
|
unsafe impl Send for MpvRenderContext {}
|
|
|
|
impl MpvRenderContext {
|
|
/// Create a render context over an existing mpv handle.
|
|
///
|
|
/// The GL context must already be current on this thread.
|
|
///
|
|
/// TRACES: UR-080 | DR-231, IR-033
|
|
pub unsafe fn new(mpv: *mut libmpv_sys::mpv_handle) -> Option<Self> {
|
|
let gl = Gl::load()?;
|
|
|
|
let mut init = libmpv_sys::mpv_opengl_init_params {
|
|
get_proc_address: Some(get_proc_address),
|
|
get_proc_address_ctx: ptr::null_mut(),
|
|
};
|
|
let mut api_type = CString::new("opengl").ok()?;
|
|
// Advanced control is deliberately OFF.
|
|
//
|
|
// With it on, mpv expects the client to drive rendering to a stricter
|
|
// contract than a GTK draw handler can promise — it will wait on us, and
|
|
// if we in turn wait on its update callback, neither side proceeds. That
|
|
// deadlock presents as a file that loads, renders one frame, and then
|
|
// sits there with no audio and a spinner.
|
|
//
|
|
// Off, mpv is tolerant of being rendered on the toolkit's schedule,
|
|
// which is what the frame clock gives us.
|
|
let mut advanced: c_int = 0;
|
|
|
|
let mut params = [
|
|
libmpv_sys::mpv_render_param {
|
|
type_: libmpv_sys::mpv_render_param_type_MPV_RENDER_PARAM_API_TYPE,
|
|
data: api_type.as_ptr() as *mut c_void,
|
|
},
|
|
libmpv_sys::mpv_render_param {
|
|
type_: libmpv_sys::mpv_render_param_type_MPV_RENDER_PARAM_OPENGL_INIT_PARAMS,
|
|
data: &mut init as *mut _ as *mut c_void,
|
|
},
|
|
libmpv_sys::mpv_render_param {
|
|
type_: libmpv_sys::mpv_render_param_type_MPV_RENDER_PARAM_ADVANCED_CONTROL,
|
|
data: &mut advanced as *mut _ as *mut c_void,
|
|
},
|
|
libmpv_sys::mpv_render_param {
|
|
type_: 0,
|
|
data: ptr::null_mut(),
|
|
},
|
|
];
|
|
|
|
let mut ctx: *mut libmpv_sys::mpv_render_context = ptr::null_mut();
|
|
let rc = libmpv_sys::mpv_render_context_create(&mut ctx, mpv, params.as_mut_ptr());
|
|
// Keep the CString alive until after the call.
|
|
let _ = &mut api_type;
|
|
|
|
if rc < 0 || ctx.is_null() {
|
|
error!("[MpvRender] mpv_render_context_create failed: {rc}");
|
|
return None;
|
|
}
|
|
|
|
info!("[MpvRender] render context created");
|
|
Some(MpvRenderContext {
|
|
ctx,
|
|
gl,
|
|
target: None,
|
|
})
|
|
}
|
|
|
|
/// Ask to be told when a new frame is ready.
|
|
///
|
|
/// Paired with [`report_swap`](Self::report_swap): without both, mpv has
|
|
/// nothing to time against. The symptom is misleading — playback looks fine
|
|
/// in a window and judders at fullscreen, which reads as a compositing or
|
|
/// GPU limit and is neither (DR-233).
|
|
///
|
|
/// TRACES: UR-080 | DR-233
|
|
pub unsafe fn set_update_callback(
|
|
&mut self,
|
|
callback: libmpv_sys::mpv_render_update_fn,
|
|
ctx: *mut c_void,
|
|
) {
|
|
libmpv_sys::mpv_render_context_set_update_callback(self.ctx, callback, ctx);
|
|
}
|
|
|
|
/// Whether mpv has a new frame waiting.
|
|
///
|
|
/// Asked of mpv directly rather than inferred from its update callback, and
|
|
/// that distinction is the whole of frame pacing here:
|
|
///
|
|
/// - Waiting only on the callback deadlocks — mpv will not progress until
|
|
/// the client renders, so if the client will not render until mpv says
|
|
/// so, neither moves. That presents as a file that loads, shows one
|
|
/// frame, and then sits silent.
|
|
/// - Rendering on *every* frame-clock tick regardless is the opposite
|
|
/// error: `report_swap` then claims a presentation far more often than
|
|
/// real frames exist, mpv has nothing coherent to time against, and
|
|
/// playback judders badly.
|
|
///
|
|
/// Polling is neither. It runs on the main thread, costs a single atomic
|
|
/// read inside mpv, and answers the only question that matters.
|
|
///
|
|
/// TRACES: UR-080 | DR-233
|
|
pub unsafe fn has_frame(&self) -> bool {
|
|
let flags = libmpv_sys::mpv_render_context_update(self.ctx);
|
|
(flags & libmpv_sys::mpv_render_update_flag_MPV_RENDER_UPDATE_FRAME as u64) != 0
|
|
}
|
|
|
|
/// Render the current frame at `width` x `height`, returning the texture id
|
|
/// holding it. The GL context must be current.
|
|
///
|
|
/// TRACES: UR-080 | DR-231
|
|
pub unsafe fn render(&mut self, width: i32, height: i32) -> Option<u32> {
|
|
if width <= 0 || height <= 0 {
|
|
return None;
|
|
}
|
|
self.ensure_target(width, height)?;
|
|
let target = self.target.as_ref()?;
|
|
|
|
let mut fbo = libmpv_sys::mpv_opengl_fbo {
|
|
fbo: target.fbo as c_int,
|
|
w: width as c_int,
|
|
h: height as c_int,
|
|
internal_format: 0,
|
|
};
|
|
// GTK's cairo surface has its origin at the top left; mpv defaults to
|
|
// OpenGL's bottom-left. Without this the picture is drawn upside down —
|
|
// which looks like a broken decode rather than a coordinate convention.
|
|
let mut flip: c_int = 1;
|
|
|
|
let mut params = [
|
|
libmpv_sys::mpv_render_param {
|
|
type_: libmpv_sys::mpv_render_param_type_MPV_RENDER_PARAM_OPENGL_FBO,
|
|
data: &mut fbo as *mut _ as *mut c_void,
|
|
},
|
|
libmpv_sys::mpv_render_param {
|
|
type_: libmpv_sys::mpv_render_param_type_MPV_RENDER_PARAM_FLIP_Y,
|
|
data: &mut flip as *mut _ as *mut c_void,
|
|
},
|
|
libmpv_sys::mpv_render_param {
|
|
type_: 0,
|
|
data: ptr::null_mut(),
|
|
},
|
|
];
|
|
|
|
let rc = libmpv_sys::mpv_render_context_render(self.ctx, params.as_mut_ptr());
|
|
if rc < 0 {
|
|
warn!("[MpvRender] render failed: {rc}");
|
|
return None;
|
|
}
|
|
Some(target.texture)
|
|
}
|
|
|
|
/// Tell mpv the frame reached the screen. See [`set_update_callback`].
|
|
///
|
|
/// TRACES: UR-080 | DR-233
|
|
pub unsafe fn report_swap(&self) {
|
|
libmpv_sys::mpv_render_context_report_swap(self.ctx);
|
|
}
|
|
|
|
/// Create or resize the framebuffer. Reused across frames — reallocating per
|
|
/// frame would churn GPU memory at the display rate.
|
|
unsafe fn ensure_target(&mut self, width: i32, height: i32) -> Option<()> {
|
|
if let Some(t) = &self.target {
|
|
if t.width == width && t.height == height {
|
|
return Some(());
|
|
}
|
|
}
|
|
self.drop_target();
|
|
|
|
let gl = &self.gl;
|
|
let mut texture: u32 = 0;
|
|
(gl.gen_textures)(1, &mut texture);
|
|
(gl.bind_texture)(GL_TEXTURE_2D, texture);
|
|
(gl.tex_image_2d)(
|
|
GL_TEXTURE_2D,
|
|
0,
|
|
GL_RGBA8,
|
|
width,
|
|
height,
|
|
0,
|
|
GL_RGBA,
|
|
GL_UNSIGNED_BYTE,
|
|
ptr::null(),
|
|
);
|
|
(gl.tex_parameteri)(GL_TEXTURE_2D, GL_TEXTURE_MIN_FILTER, GL_LINEAR);
|
|
(gl.tex_parameteri)(GL_TEXTURE_2D, GL_TEXTURE_MAG_FILTER, GL_LINEAR);
|
|
(gl.bind_texture)(GL_TEXTURE_2D, 0);
|
|
|
|
let mut fbo: u32 = 0;
|
|
(gl.gen_framebuffers)(1, &mut fbo);
|
|
(gl.bind_framebuffer)(GL_FRAMEBUFFER, fbo);
|
|
(gl.framebuffer_texture_2d)(
|
|
GL_FRAMEBUFFER,
|
|
GL_COLOR_ATTACHMENT0,
|
|
GL_TEXTURE_2D,
|
|
texture,
|
|
0,
|
|
);
|
|
let status = (gl.check_framebuffer_status)(GL_FRAMEBUFFER);
|
|
(gl.bind_framebuffer)(GL_FRAMEBUFFER, 0);
|
|
|
|
if status != GL_FRAMEBUFFER_COMPLETE {
|
|
error!("[MpvRender] framebuffer incomplete: 0x{status:x}");
|
|
(gl.delete_framebuffers)(1, &fbo);
|
|
(gl.delete_textures)(1, &texture);
|
|
return None;
|
|
}
|
|
|
|
self.target = Some(Target {
|
|
fbo,
|
|
texture,
|
|
width,
|
|
height,
|
|
});
|
|
Some(())
|
|
}
|
|
|
|
unsafe fn drop_target(&mut self) {
|
|
if let Some(t) = self.target.take() {
|
|
(self.gl.delete_framebuffers)(1, &t.fbo);
|
|
(self.gl.delete_textures)(1, &t.texture);
|
|
}
|
|
}
|
|
|
|
/// Free everything, with the GL context current.
|
|
///
|
|
/// Explicit rather than left to `Drop` because the ordering matters and the
|
|
/// caller is the only one that can guarantee the GL context is current. See
|
|
/// DR-232.
|
|
pub unsafe fn destroy(mut self) {
|
|
// Unregister first: a callback arriving after the free would be a use
|
|
// after free, and it is scheduled from mpv's own threads.
|
|
libmpv_sys::mpv_render_context_set_update_callback(self.ctx, None, ptr::null_mut());
|
|
self.drop_target();
|
|
libmpv_sys::mpv_render_context_free(self.ctx);
|
|
self.ctx = ptr::null_mut();
|
|
info!("[MpvRender] render context freed");
|
|
std::mem::forget(self);
|
|
}
|
|
}
|
|
|
|
impl Drop for MpvRenderContext {
|
|
fn drop(&mut self) {
|
|
if !self.ctx.is_null() {
|
|
// Reached only if `destroy` was not called — the GL context may not
|
|
// be current, so the GL objects are deliberately leaked rather than
|
|
// deleted against whatever context happens to be bound. Freeing the
|
|
// render context is still safe and is the part that matters.
|
|
warn!("[MpvRender] dropped without destroy(); GL objects leaked deliberately");
|
|
unsafe {
|
|
libmpv_sys::mpv_render_context_set_update_callback(self.ctx, None, ptr::null_mut());
|
|
libmpv_sys::mpv_render_context_free(self.ctx);
|
|
}
|
|
}
|
|
}
|
|
}
|