jellytau_lib/player/mpv_render.rs
1//! mpv's render API, driven into an OpenGL framebuffer we own.
2//!
3//! This is the half of native video that is not GTK: create a render context
4//! over the mpv handle the audio backend already drives, render a frame into a
5//! texture, and hand that texture id back for the toolkit to composite.
6//!
7//! Kept apart from `video_surface` deliberately — everything here is portable
8//! across the platforms this app targets, while the surface that consumes it is
9//! not. Windows reuses this file unchanged (DR-237).
10//!
11//! TRACES: UR-080 | DR-231, DR-232, IR-033
12
13use std::ffi::{c_void, CStr, CString};
14use std::os::raw::{c_char, c_int};
15use std::ptr;
16
17use log::{error, info, warn};
18
19/// GL entry points, resolved once.
20///
21/// Only the handful needed to own a framebuffer; mpv resolves everything else
22/// it needs through [`get_proc_address`].
23struct Gl {
24 gen_framebuffers: unsafe extern "C" fn(c_int, *mut u32),
25 delete_framebuffers: unsafe extern "C" fn(c_int, *const u32),
26 bind_framebuffer: unsafe extern "C" fn(u32, u32),
27 framebuffer_texture_2d: unsafe extern "C" fn(u32, u32, u32, u32, c_int),
28 gen_textures: unsafe extern "C" fn(c_int, *mut u32),
29 delete_textures: unsafe extern "C" fn(c_int, *const u32),
30 bind_texture: unsafe extern "C" fn(u32, u32),
31 tex_image_2d:
32 unsafe extern "C" fn(u32, c_int, c_int, c_int, c_int, c_int, u32, u32, *const c_void),
33 tex_parameteri: unsafe extern "C" fn(u32, u32, c_int),
34 check_framebuffer_status: unsafe extern "C" fn(u32) -> u32,
35}
36
37const GL_TEXTURE_2D: u32 = 0x0DE1;
38const GL_FRAMEBUFFER: u32 = 0x8D40;
39const GL_COLOR_ATTACHMENT0: u32 = 0x8CE0;
40const GL_RGBA: u32 = 0x1908;
41const GL_RGBA8: c_int = 0x8058;
42const GL_UNSIGNED_BYTE: u32 = 0x1401;
43const GL_LINEAR: c_int = 0x2601;
44const GL_TEXTURE_MIN_FILTER: u32 = 0x2801;
45const GL_TEXTURE_MAG_FILTER: u32 = 0x2800;
46const GL_FRAMEBUFFER_COMPLETE: u32 = 0x8CD5;
47
48/// Resolve a GL symbol the way libepoxy actually exports it.
49///
50/// **This is the trap that cost the spike a debugging cycle.** libepoxy does not
51/// export `glFoo` as a function. It exports `epoxy_glFoo` as a *data* symbol
52/// holding a lazily-resolving function pointer. So the address `dlsym` returns
53/// is the address *of the pointer*, not of any code: returning it makes mpv jump
54/// into non-executable data and take SIGSEGV/SEGV_ACCERR on the very first GL
55/// call. The value must be read *out of* that location.
56///
57/// The `epoxy` crate does this correctly and is unusable here — its
58/// `gl_generator` dependency pulls a yanked `xml-rs`.
59///
60/// TRACES: UR-080 | IR-033
61unsafe fn resolve(name: &str) -> *mut c_void {
62 let epoxy_name = match CString::new(format!("epoxy_{name}")) {
63 Ok(n) => n,
64 Err(_) => return ptr::null_mut(),
65 };
66 let slot = libc::dlsym(libc::RTLD_DEFAULT, epoxy_name.as_ptr());
67 if !slot.is_null() {
68 // The symbol holds the function pointer; return what is stored there.
69 return *(slot as *mut *mut c_void);
70 }
71
72 // Fall back to a plain symbol, for a GL stack that is not behind epoxy.
73 match CString::new(name) {
74 Ok(n) => libc::dlsym(libc::RTLD_DEFAULT, n.as_ptr()),
75 Err(_) => ptr::null_mut(),
76 }
77}
78
79/// What mpv calls to find GL entry points. Same rule as [`resolve`].
80unsafe extern "C" fn get_proc_address(_ctx: *mut c_void, name: *const c_char) -> *mut c_void {
81 if name.is_null() {
82 return ptr::null_mut();
83 }
84 match CStr::from_ptr(name).to_str() {
85 Ok(n) => resolve(n),
86 Err(_) => ptr::null_mut(),
87 }
88}
89
90macro_rules! load {
91 ($name:literal) => {{
92 let p = resolve($name);
93 if p.is_null() {
94 error!("[MpvRender] GL symbol not found: {}", $name);
95 return None;
96 }
97 std::mem::transmute(p)
98 }};
99}
100
101impl Gl {
102 /// Resolve every entry point, or none — a partially-loaded table would fail
103 /// later at a call site with no context.
104 ///
105 /// The transmutes are unannotated on purpose: each target type is declared
106 /// once on the struct field above, and repeating it at the call site would
107 /// be two places to get the same signature wrong.
108 #[allow(clippy::missing_transmute_annotations)]
109 unsafe fn load() -> Option<Self> {
110 Some(Gl {
111 gen_framebuffers: load!("glGenFramebuffers"),
112 delete_framebuffers: load!("glDeleteFramebuffers"),
113 bind_framebuffer: load!("glBindFramebuffer"),
114 framebuffer_texture_2d: load!("glFramebufferTexture2D"),
115 gen_textures: load!("glGenTextures"),
116 delete_textures: load!("glDeleteTextures"),
117 bind_texture: load!("glBindTexture"),
118 tex_image_2d: load!("glTexImage2D"),
119 tex_parameteri: load!("glTexParameteri"),
120 check_framebuffer_status: load!("glCheckFramebufferStatus"),
121 })
122 }
123}
124
125/// A colour-renderable framebuffer mpv draws into, sized to the widget.
126struct Target {
127 fbo: u32,
128 texture: u32,
129 width: i32,
130 height: i32,
131}
132
133/// mpv's render context plus the framebuffer it draws into.
134///
135/// # Lifetime (DR-232)
136///
137/// The render context must not outlive the GL context it was created against.
138/// `Drop` unregisters mpv's update callback *before* freeing the context, so a
139/// callback cannot land on a freed pointer, and frees the GL objects while the
140/// caller still has the context current. The caller is responsible for making
141/// the GL context current around both creation and drop — see `video_surface`.
142///
143/// This is DR-184 on Android restated: a surface outliving its player. The spike
144/// had no defence at all and saw one unexplained SIGSEGV in a decoder thread.
145pub struct MpvRenderContext {
146 ctx: *mut libmpv_sys::mpv_render_context,
147 gl: Gl,
148 target: Option<Target>,
149}
150
151// The render context is driven only from the GTK main thread; the update
152// callback merely schedules a redraw and touches nothing here.
153unsafe impl Send for MpvRenderContext {}
154
155impl MpvRenderContext {
156 /// Create a render context over an existing mpv handle.
157 ///
158 /// The GL context must already be current on this thread.
159 ///
160 /// TRACES: UR-080 | DR-231, IR-033
161 pub unsafe fn new(mpv: *mut libmpv_sys::mpv_handle) -> Option<Self> {
162 let gl = Gl::load()?;
163
164 let mut init = libmpv_sys::mpv_opengl_init_params {
165 get_proc_address: Some(get_proc_address),
166 get_proc_address_ctx: ptr::null_mut(),
167 };
168 let mut api_type = CString::new("opengl").ok()?;
169 // Advanced control is deliberately OFF.
170 //
171 // With it on, mpv expects the client to drive rendering to a stricter
172 // contract than a GTK draw handler can promise — it will wait on us, and
173 // if we in turn wait on its update callback, neither side proceeds. That
174 // deadlock presents as a file that loads, renders one frame, and then
175 // sits there with no audio and a spinner.
176 //
177 // Off, mpv is tolerant of being rendered on the toolkit's schedule,
178 // which is what the frame clock gives us.
179 let mut advanced: c_int = 0;
180
181 let mut params = [
182 libmpv_sys::mpv_render_param {
183 type_: libmpv_sys::mpv_render_param_type_MPV_RENDER_PARAM_API_TYPE,
184 data: api_type.as_ptr() as *mut c_void,
185 },
186 libmpv_sys::mpv_render_param {
187 type_: libmpv_sys::mpv_render_param_type_MPV_RENDER_PARAM_OPENGL_INIT_PARAMS,
188 data: &mut init as *mut _ as *mut c_void,
189 },
190 libmpv_sys::mpv_render_param {
191 type_: libmpv_sys::mpv_render_param_type_MPV_RENDER_PARAM_ADVANCED_CONTROL,
192 data: &mut advanced as *mut _ as *mut c_void,
193 },
194 libmpv_sys::mpv_render_param {
195 type_: 0,
196 data: ptr::null_mut(),
197 },
198 ];
199
200 let mut ctx: *mut libmpv_sys::mpv_render_context = ptr::null_mut();
201 let rc = libmpv_sys::mpv_render_context_create(&mut ctx, mpv, params.as_mut_ptr());
202 // Keep the CString alive until after the call.
203 let _ = &mut api_type;
204
205 if rc < 0 || ctx.is_null() {
206 error!("[MpvRender] mpv_render_context_create failed: {rc}");
207 return None;
208 }
209
210 info!("[MpvRender] render context created");
211 Some(MpvRenderContext {
212 ctx,
213 gl,
214 target: None,
215 })
216 }
217
218 /// Ask to be told when a new frame is ready.
219 ///
220 /// Paired with [`report_swap`](Self::report_swap): without both, mpv has
221 /// nothing to time against. The symptom is misleading — playback looks fine
222 /// in a window and judders at fullscreen, which reads as a compositing or
223 /// GPU limit and is neither (DR-233).
224 ///
225 /// TRACES: UR-080 | DR-233
226 pub unsafe fn set_update_callback(
227 &mut self,
228 callback: libmpv_sys::mpv_render_update_fn,
229 ctx: *mut c_void,
230 ) {
231 libmpv_sys::mpv_render_context_set_update_callback(self.ctx, callback, ctx);
232 }
233
234 /// Whether mpv has a new frame waiting.
235 ///
236 /// Asked of mpv directly rather than inferred from its update callback, and
237 /// that distinction is the whole of frame pacing here:
238 ///
239 /// - Waiting only on the callback deadlocks — mpv will not progress until
240 /// the client renders, so if the client will not render until mpv says
241 /// so, neither moves. That presents as a file that loads, shows one
242 /// frame, and then sits silent.
243 /// - Rendering on *every* frame-clock tick regardless is the opposite
244 /// error: `report_swap` then claims a presentation far more often than
245 /// real frames exist, mpv has nothing coherent to time against, and
246 /// playback judders badly.
247 ///
248 /// Polling is neither. It runs on the main thread, costs a single atomic
249 /// read inside mpv, and answers the only question that matters.
250 ///
251 /// TRACES: UR-080 | DR-233
252 pub unsafe fn has_frame(&self) -> bool {
253 let flags = libmpv_sys::mpv_render_context_update(self.ctx);
254 (flags & libmpv_sys::mpv_render_update_flag_MPV_RENDER_UPDATE_FRAME as u64) != 0
255 }
256
257 /// Render the current frame at `width` x `height`, returning the texture id
258 /// holding it. The GL context must be current.
259 ///
260 /// TRACES: UR-080 | DR-231
261 pub unsafe fn render(&mut self, width: i32, height: i32) -> Option<u32> {
262 if width <= 0 || height <= 0 {
263 return None;
264 }
265 self.ensure_target(width, height)?;
266 let target = self.target.as_ref()?;
267
268 let mut fbo = libmpv_sys::mpv_opengl_fbo {
269 fbo: target.fbo as c_int,
270 w: width as c_int,
271 h: height as c_int,
272 internal_format: 0,
273 };
274 // GTK's cairo surface has its origin at the top left; mpv defaults to
275 // OpenGL's bottom-left. Without this the picture is drawn upside down —
276 // which looks like a broken decode rather than a coordinate convention.
277 let mut flip: c_int = 1;
278
279 let mut params = [
280 libmpv_sys::mpv_render_param {
281 type_: libmpv_sys::mpv_render_param_type_MPV_RENDER_PARAM_OPENGL_FBO,
282 data: &mut fbo as *mut _ as *mut c_void,
283 },
284 libmpv_sys::mpv_render_param {
285 type_: libmpv_sys::mpv_render_param_type_MPV_RENDER_PARAM_FLIP_Y,
286 data: &mut flip as *mut _ as *mut c_void,
287 },
288 libmpv_sys::mpv_render_param {
289 type_: 0,
290 data: ptr::null_mut(),
291 },
292 ];
293
294 let rc = libmpv_sys::mpv_render_context_render(self.ctx, params.as_mut_ptr());
295 if rc < 0 {
296 warn!("[MpvRender] render failed: {rc}");
297 return None;
298 }
299 Some(target.texture)
300 }
301
302 /// Tell mpv the frame reached the screen. See [`set_update_callback`].
303 ///
304 /// TRACES: UR-080 | DR-233
305 pub unsafe fn report_swap(&self) {
306 libmpv_sys::mpv_render_context_report_swap(self.ctx);
307 }
308
309 /// Create or resize the framebuffer. Reused across frames — reallocating per
310 /// frame would churn GPU memory at the display rate.
311 unsafe fn ensure_target(&mut self, width: i32, height: i32) -> Option<()> {
312 if let Some(t) = &self.target {
313 if t.width == width && t.height == height {
314 return Some(());
315 }
316 }
317 self.drop_target();
318
319 let gl = &self.gl;
320 let mut texture: u32 = 0;
321 (gl.gen_textures)(1, &mut texture);
322 (gl.bind_texture)(GL_TEXTURE_2D, texture);
323 (gl.tex_image_2d)(
324 GL_TEXTURE_2D,
325 0,
326 GL_RGBA8,
327 width,
328 height,
329 0,
330 GL_RGBA,
331 GL_UNSIGNED_BYTE,
332 ptr::null(),
333 );
334 (gl.tex_parameteri)(GL_TEXTURE_2D, GL_TEXTURE_MIN_FILTER, GL_LINEAR);
335 (gl.tex_parameteri)(GL_TEXTURE_2D, GL_TEXTURE_MAG_FILTER, GL_LINEAR);
336 (gl.bind_texture)(GL_TEXTURE_2D, 0);
337
338 let mut fbo: u32 = 0;
339 (gl.gen_framebuffers)(1, &mut fbo);
340 (gl.bind_framebuffer)(GL_FRAMEBUFFER, fbo);
341 (gl.framebuffer_texture_2d)(
342 GL_FRAMEBUFFER,
343 GL_COLOR_ATTACHMENT0,
344 GL_TEXTURE_2D,
345 texture,
346 0,
347 );
348 let status = (gl.check_framebuffer_status)(GL_FRAMEBUFFER);
349 (gl.bind_framebuffer)(GL_FRAMEBUFFER, 0);
350
351 if status != GL_FRAMEBUFFER_COMPLETE {
352 error!("[MpvRender] framebuffer incomplete: 0x{status:x}");
353 (gl.delete_framebuffers)(1, &fbo);
354 (gl.delete_textures)(1, &texture);
355 return None;
356 }
357
358 self.target = Some(Target {
359 fbo,
360 texture,
361 width,
362 height,
363 });
364 Some(())
365 }
366
367 unsafe fn drop_target(&mut self) {
368 if let Some(t) = self.target.take() {
369 (self.gl.delete_framebuffers)(1, &t.fbo);
370 (self.gl.delete_textures)(1, &t.texture);
371 }
372 }
373
374 /// Free everything, with the GL context current.
375 ///
376 /// Explicit rather than left to `Drop` because the ordering matters and the
377 /// caller is the only one that can guarantee the GL context is current. See
378 /// DR-232.
379 pub unsafe fn destroy(mut self) {
380 // Unregister first: a callback arriving after the free would be a use
381 // after free, and it is scheduled from mpv's own threads.
382 libmpv_sys::mpv_render_context_set_update_callback(self.ctx, None, ptr::null_mut());
383 self.drop_target();
384 libmpv_sys::mpv_render_context_free(self.ctx);
385 self.ctx = ptr::null_mut();
386 info!("[MpvRender] render context freed");
387 std::mem::forget(self);
388 }
389}
390
391impl Drop for MpvRenderContext {
392 fn drop(&mut self) {
393 if !self.ctx.is_null() {
394 // Reached only if `destroy` was not called — the GL context may not
395 // be current, so the GL objects are deliberately leaked rather than
396 // deleted against whatever context happens to be bound. Freeing the
397 // render context is still safe and is the part that matters.
398 warn!("[MpvRender] dropped without destroy(); GL objects leaked deliberately");
399 unsafe {
400 libmpv_sys::mpv_render_context_set_update_callback(self.ctx, None, ptr::null_mut());
401 libmpv_sys::mpv_render_context_free(self.ctx);
402 }
403 }
404 }
405}