//! 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 { 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, } // 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 { 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 { 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); } } } }