tawasuyu/llimphi
Sovereign retained-mode GPU UI framework in Rust (wgpu+vello+taffy+parley), Elm loop, 40+ widgets.
⎇ c5a232cf24788ab75a850ffe81eca5a7af50e2f8
llimphi-video-plane/src/main.rs
Historia
Quién tocó qué
crudo
1//! Spike de "atachá video en llimphi": video como `wl_subsurface` real sobre
2//! la `wl_surface` de winit, para que mirada lo componga (Fase 1) y a futuro lo
3//! escanee en un plano de overlay, **sin pasar por la textura intermedia de
4//! vello**.
5//!
6//! Muro que valida (Fase 0, ya OK): winit no expone subsurfaces, así que se
7//! monta una **segunda** conexión `wayland-client` sobre el MISMO `wl_display`
8//! ajeno (`Backend::from_foreign_display`), se envuelve la `wl_surface` de winit
9//! por `ObjectId::from_ptr`, y se le crea una `wl_subsurface` hija. `set_desync`
10//! → el hijo commitea solo; posición/orden se latchean en el commit del padre
11//! (que hace winit al presentar; el subsurface trackea al padre al mover).
12//!
13//! Dos modos:
14//! - **sin argumentos** → cuadro de color sólido (valida el subsurface).
15//! - **`subsurface-spike <archivo>`** → frames reales de ese video (Fase 1a),
16//! decodificados por `foreign-av`, subidos al subsurface por shm (3 buffers
17//! round-robin, RGBA→ARGB8888). La UI winit sólo limpia el fondo.
18//!
19//! Teclas: B = subsurface debajo/encima del padre · T = fondo UI
20//! transparente/opaco (el "hueco" donde asoma el plano) · ESC = salir.
21
22use std::os::fd::{AsFd, BorrowedFd};
23use std::sync::Arc;
24use std::time::{Duration, Instant};
25
26use gbm::{BufferObject, BufferObjectFlags, Device as GbmDevice, Format as GbmFormat};
27use media_core::FrameSource;
28use raw_window_handle::{HasDisplayHandle, HasWindowHandle, RawDisplayHandle, RawWindowHandle};
29use wayland_client::backend::{Backend, ObjectId};
30use wayland_client::protocol::{
31 wl_buffer::WlBuffer,
32 wl_compositor::WlCompositor,
33 wl_region::WlRegion,
34 wl_registry,
35 wl_shm::{self, WlShm},
36 wl_shm_pool::WlShmPool,
37 wl_subcompositor::WlSubcompositor,
38 wl_subsurface::WlSubsurface,
39 wl_surface::WlSurface,
40};
41use wayland_client::{Connection, Dispatch, EventQueue, Proxy, QueueHandle};
42use wayland_protocols::wp::linux_dmabuf::zv1::client::{
43 zwp_linux_buffer_params_v1::{self, ZwpLinuxBufferParamsV1},
44 zwp_linux_dmabuf_v1::ZwpLinuxDmabufV1,
45};
46use winit::application::ApplicationHandler;
47use winit::event::{ElementState, KeyEvent, WindowEvent};
48use winit::event_loop::{ActiveEventLoop, ControlFlow, EventLoop};
49use winit::keyboard::{Key, NamedKey};
50use winit::window::{Window, WindowId};
51
52// Modo cuadro sólido (sin archivo).
53const SOLID_W: i32 = 400;
54const SOLID_H: i32 = 300;
55const SOLID_X: i32 = 200;
56const SOLID_Y: i32 = 150;
57const DEFAULT_WIN: (u32, u32) = (800, 600);
58
59/// Cuántos buffers shm rotamos. Con 3 en round-robin nunca reescribimos uno que
60/// el compositor todavía tenga tomado (a 30 fps lo libera en ~1 frame ≪ 3), así
61/// que no hace falta rastrear `wl_buffer.release`.
62const RING: usize = 3;
63
64// ---------------------------------------------------------------------------
65// Estado wayland del lado del subsurface (segunda conexión, display ajeno).
66// ---------------------------------------------------------------------------
67
68#[derive(Default)]
69struct WlGlobals {
70 compositor: Option<WlCompositor>,
71 subcompositor: Option<WlSubcompositor>,
72 shm: Option<WlShm>,
73 dmabuf: Option<ZwpLinuxDmabufV1>,
74}
75
76impl Dispatch<wl_registry::WlRegistry, ()> for WlGlobals {
77 fn event(
78 state: &mut Self,
79 registry: &wl_registry::WlRegistry,
80 event: wl_registry::Event,
81 _: &(),
82 _: &Connection,
83 qh: &QueueHandle<Self>,
84 ) {
85 if let wl_registry::Event::Global { name, interface, version } = event {
86 match &interface[..] {
87 "wl_compositor" => {
88 state.compositor = Some(registry.bind(name, version.min(4), qh, ()));
89 }
90 "wl_subcompositor" => {
91 state.subcompositor = Some(registry.bind(name, 1, qh, ()));
92 }
93 "wl_shm" => {
94 state.shm = Some(registry.bind(name, 1, qh, ()));
95 }
96 "zwp_linux_dmabuf_v1" => {
97 // v3+ para `add` con modifier; v4 trae feedback (no lo usamos).
98 state.dmabuf = Some(registry.bind(name, version.min(4), qh, ()));
99 }
100 _ => {}
101 }
102 }
103 }
104}
105
106/// Impls `Dispatch` vacíos: no nos interesan los eventos de estos objetos.
107macro_rules! empty_dispatch {
108 ($($t:ty),* $(,)?) => { $(
109 impl Dispatch<$t, ()> for WlGlobals {
110 fn event(
111 _: &mut Self,
112 _: &$t,
113 _: <$t as Proxy>::Event,
114 _: &(),
115 _: &Connection,
116 _: &QueueHandle<Self>,
117 ) {}
118 }
119 )* };
120}
121empty_dispatch!(
122 WlCompositor,
123 WlSubcompositor,
124 WlShm,
125 WlShmPool,
126 WlBuffer,
127 WlSurface,
128 WlSubsurface,
129 WlRegion,
130 ZwpLinuxDmabufV1,
131 ZwpLinuxBufferParamsV1,
132);
133
134// ---------------------------------------------------------------------------
135// Alimentador de frames de video al subsurface por shm (round-robin).
136// ---------------------------------------------------------------------------
137
138/// Backend de almacenamiento de los frames: shm (CPU, jamás escanea a plano) o
139/// dmabuf (buffers `gbm` LINEAR scanout-capable → mirada los puede promover a un
140/// plano de overlay del DRM). En ambos el `Feeder` escribe el frame por CPU; la
141/// diferencia es qué puede hacer el compositor con el resultado.
142enum Ring {
143 /// Mapeo único del pool shm (RING × frame contiguos). Puntero crudo: la app
144 /// es mono-hilo, no cruza threads.
145 Shm { map: *mut u8, frame_bytes: usize },
146 /// Un `BufferObject` gbm por slot del ring. El `Device` se retiene (los bo
147 /// comparten su `gbm_device` por refcount ffi, pero el Rust `Device` posee el
148 /// destructor). Se escribe por `map_mut` (sync ioctls incluidos).
149 Dmabuf {
150 _device: GbmDevice<std::fs::File>,
151 bos: Vec<BufferObject<()>>,
152 },
153}
154
155struct Feeder {
156 source: Box<dyn FrameSource + Send>,
157 child: WlSurface,
158 conn: Connection,
159 ring: Ring,
160 w: i32,
161 h: i32,
162 buffers: Vec<WlBuffer>,
163 next: usize,
164 rgba: Vec<u8>,
165 last: Instant,
166 interval: Duration,
167 /// Contador de throughput real (frames commiteados por ventana de ~1s).
168 frames_win: u32,
169 fps_t0: Instant,
170}
171
172impl Feeder {
173 /// Tira del próximo frame (si toca por cadencia) y lo commitea al hijo.
174 fn pump(&mut self, now: Instant) {
175 let dt = now.saturating_duration_since(self.last);
176 self.last = now;
177 let Some((w, h)) = self.source.tick(dt, &mut self.rgba) else {
178 return;
179 };
180 if w as i32 != self.w || h as i32 != self.h {
181 // El spike fija tamaño al abrir; un cambio de resolución en vuelo no
182 // se maneja acá.
183 return;
184 }
185 let idx = self.next;
186 self.next = (self.next + 1) % self.buffers.len();
187
188 // RGBA (del FrameSource) → XRGB8888 (bytes B,G,R,X en memoria LE).
189 let px = (w * h) as usize;
190 match &mut self.ring {
191 Ring::Shm { map, frame_bytes } => {
192 let base = unsafe { map.add(idx * *frame_bytes) as *mut u32 };
193 for i in 0..px {
194 let r = self.rgba[i * 4] as u32;
195 let g = self.rgba[i * 4 + 1] as u32;
196 let b = self.rgba[i * 4 + 2] as u32;
197 unsafe { base.add(i).write((r << 16) | (g << 8) | b) };
198 }
199 }
200 Ring::Dmabuf { bos, .. } => {
201 // `map_mut` respeta el stride del mapeo (puede diferir de w*4).
202 let rgba = &self.rgba;
203 let _ = bos[idx].map_mut(0, 0, w, h, |m| {
204 let stride = m.stride() as usize;
205 let buf = m.buffer_mut();
206 for y in 0..h as usize {
207 let dst_row = y * stride;
208 let src_row = y * w as usize * 4;
209 for x in 0..w as usize {
210 let s = src_row + x * 4;
211 let r = rgba[s] as u32;
212 let g = rgba[s + 1] as u32;
213 let b = rgba[s + 2] as u32;
214 let word = (r << 16) | (g << 8) | b;
215 let d = dst_row + x * 4;
216 buf[d..d + 4].copy_from_slice(&word.to_ne_bytes());
217 }
218 }
219 });
220 }
221 }
222
223 self.child.attach(Some(&self.buffers[idx]), 0, 0);
224 self.child.damage_buffer(0, 0, self.w, self.h);
225 self.child.commit();
226 let _ = self.conn.flush();
227
228 // Throughput real (gateado por VIDEO_PLANE_FPS): frames commiteados/s.
229 self.frames_win += 1;
230 if self.fps_t0.elapsed() >= Duration::from_secs(1) {
231 if std::env::var_os("VIDEO_PLANE_FPS").is_some() {
232 eprintln!("[spike] pump: {} fps reales", self.frames_win);
233 }
234 self.frames_win = 0;
235 self.fps_t0 = Instant::now();
236 }
237 }
238}
239
240// ---------------------------------------------------------------------------
241// El plano: subsurface + su contenido (cuadro sólido o alimentador de video).
242// ---------------------------------------------------------------------------
243
244enum Content {
245 Solid(#[allow(dead_code)] WlBuffer),
246 Video(Feeder),
247}
248
249struct VideoPlane {
250 conn: Connection,
251 queue: EventQueue<WlGlobals>,
252 globals: WlGlobals,
253 parent: WlSurface,
254 _child: WlSurface,
255 subsurface: WlSubsurface,
256 content: Content,
257 below: bool,
258}
259
260impl VideoPlane {
261 /// Monta la segunda conexión sobre el display de winit y crea el subsurface.
262 /// `video` = fuente ya abierta (y sus dims/fps) para el modo video; `None`
263 /// = cuadro sólido.
264 unsafe fn atachar(
265 window: &Window,
266 video: Option<(Box<dyn FrameSource + Send>, u32, u32, f32)>,
267 ) -> Result<Self, String> {
268 let RawDisplayHandle::Wayland(dh) = window
269 .display_handle()
270 .map_err(|e| format!("display_handle: {e}"))?
271 .as_raw()
272 else {
273 return Err("no estamos bajo Wayland (display handle no-Wayland)".into());
274 };
275 let RawWindowHandle::Wayland(wh) = window
276 .window_handle()
277 .map_err(|e| format!("window_handle: {e}"))?
278 .as_raw()
279 else {
280 return Err("no estamos bajo Wayland (window handle no-Wayland)".into());
281 };
282 let display_ptr = dh.display.as_ptr();
283 let surface_ptr = wh.surface.as_ptr();
284
285 let backend = unsafe { Backend::from_foreign_display(display_ptr.cast()) };
286 let conn = Connection::from_backend(backend);
287 let mut queue: EventQueue<WlGlobals> = conn.new_event_queue();
288 let qh = queue.handle();
289
290 let display = conn.display();
291 let _registry = display.get_registry(&qh, ());
292 let mut globals = WlGlobals::default();
293 queue
294 .roundtrip(&mut globals)
295 .map_err(|e| format!("roundtrip registry: {e}"))?;
296 let compositor = globals.compositor.clone().ok_or("falta wl_compositor")?;
297 let subcompositor = globals.subcompositor.clone().ok_or("falta wl_subcompositor")?;
298 let shm = globals.shm.clone().ok_or("falta wl_shm")?;
299
300 let parent_id = unsafe {
301 ObjectId::from_ptr(WlSurface::interface(), surface_ptr.cast())
302 .map_err(|e| format!("ObjectId::from_ptr(parent): {e}"))?
303 };
304 let parent = WlSurface::from_id(&conn, parent_id)
305 .map_err(|e| format!("WlSurface::from_id(parent): {e}"))?;
306
307 let child = compositor.create_surface(&qh, ());
308 let subsurface = subcompositor.get_subsurface(&child, &parent, &qh, ());
309 subsurface.set_desync();
310
311 let (content, sw, sh, sx, sy) = match video {
312 Some((source, w, h, fps)) => {
313 // Backend: dmabuf (default, F1b) salvo VIDEO_PLANE_SHM=1 o que el
314 // compositor no exponga linux-dmabuf.
315 let forzar_shm = std::env::var_os("VIDEO_PLANE_SHM").is_some();
316 let (ring, buffers) = match (forzar_shm, globals.dmabuf.clone()) {
317 (false, Some(dmabuf)) => match crear_ring_dmabuf(&dmabuf, &qh, w as i32, h as i32) {
318 Ok(rb) => {
319 eprintln!("[spike] backend = DMABUF (gbm LINEAR, scanout-capable)");
320 rb
321 }
322 Err(e) => {
323 eprintln!("[spike] dmabuf falló ({e}) — caigo a shm");
324 let (map, frame_bytes, buffers) =
325 crear_ring_shm(&shm, &qh, w as i32, h as i32)?;
326 (Ring::Shm { map, frame_bytes }, buffers)
327 }
328 },
329 _ => {
330 eprintln!("[spike] backend = SHM (jamás promueve a plano)");
331 let (map, frame_bytes, buffers) =
332 crear_ring_shm(&shm, &qh, w as i32, h as i32)?;
333 (Ring::Shm { map, frame_bytes }, buffers)
334 }
335 };
336 let fps = if fps.is_finite() && fps > 1.0 { fps } else { 30.0 };
337 let feeder = Feeder {
338 source,
339 child: child.clone(),
340 conn: conn.clone(),
341 ring,
342 w: w as i32,
343 h: h as i32,
344 buffers,
345 next: 0,
346 rgba: Vec::new(),
347 last: Instant::now(),
348 interval: Duration::from_secs_f32(1.0 / fps),
349 frames_win: 0,
350 fps_t0: Instant::now(),
351 };
352 eprintln!("[spike] video {w}x{h} @ {fps:.1}fps atachado al subsurface");
353 (Content::Video(feeder), w as i32, h as i32, 0, 0)
354 }
355 None => {
356 let buffer = crear_buffer_solido(&shm, &qh, SOLID_W, SOLID_H)?;
357 child.attach(Some(&buffer), 0, 0);
358 child.damage_buffer(0, 0, SOLID_W, SOLID_H);
359 child.commit();
360 (Content::Solid(buffer), SOLID_W, SOLID_H, SOLID_X, SOLID_Y)
361 }
362 };
363
364 subsurface.set_position(sx, sy);
365 // Región opaca: condición para que mirada lo mande a un plano de overlay
366 // (Fase 1b). No cambia la visibilidad ahora.
367 let region = compositor.create_region(&qh, ());
368 region.add(0, 0, sw, sh);
369 child.set_opaque_region(Some(®ion));
370 region.destroy();
371 child.commit();
372
373 conn.flush().map_err(|e| format!("flush: {e}"))?;
374
375 Ok(Self {
376 conn,
377 queue,
378 globals,
379 parent,
380 _child: child,
381 subsurface,
382 content,
383 below: false,
384 })
385 }
386
387 fn frame_interval(&self) -> Option<Duration> {
388 match &self.content {
389 Content::Video(f) => Some(f.interval),
390 Content::Solid(_) => None,
391 }
392 }
393
394 fn set_below(&mut self, below: bool) {
395 self.below = below;
396 if below {
397 self.subsurface.place_below(&self.parent);
398 } else {
399 self.subsurface.place_above(&self.parent);
400 }
401 let _ = self.conn.flush();
402 }
403
404 /// Empuja frames de video (si aplica) y drena eventos de la queue.
405 fn pump(&mut self, now: Instant) {
406 if let Content::Video(f) = &mut self.content {
407 f.pump(now);
408 }
409 let _ = self.queue.dispatch_pending(&mut self.globals);
410 let _ = self.conn.flush();
411 }
412}
413
414// ---------------------------------------------------------------------------
415// Helpers shm
416// ---------------------------------------------------------------------------
417
418/// Crea un memfd de `size` bytes y lo mapea. Devuelve (fd, puntero).
419fn memfd_map(size: usize) -> Result<(i32, *mut u8), String> {
420 let fd = unsafe { libc::memfd_create(c"llimphi-video-plane".as_ptr(), 0) };
421 if fd < 0 {
422 return Err("memfd_create falló".into());
423 }
424 if unsafe { libc::ftruncate(fd, size as libc::off_t) } != 0 {
425 unsafe { libc::close(fd) };
426 return Err("ftruncate falló".into());
427 }
428 let map = unsafe {
429 libc::mmap(
430 std::ptr::null_mut(),
431 size,
432 libc::PROT_READ | libc::PROT_WRITE,
433 libc::MAP_SHARED,
434 fd,
435 0,
436 )
437 };
438 if map == libc::MAP_FAILED {
439 unsafe { libc::close(fd) };
440 return Err("mmap falló".into());
441 }
442 Ok((fd, map as *mut u8))
443}
444
445/// Pool shm con `RING` buffers XRGB8888 de `w×h` contiguos. Devuelve el mapeo,
446/// el tamaño de un frame, y los `wl_buffer`.
447fn crear_ring_shm(
448 shm: &WlShm,
449 qh: &QueueHandle<WlGlobals>,
450 w: i32,
451 h: i32,
452) -> Result<(*mut u8, usize, Vec<WlBuffer>), String> {
453 let stride = w * 4;
454 let frame_bytes = (stride * h) as usize;
455 let total = frame_bytes * RING;
456 let (fd, map) = memfd_map(total)?;
457 let borrowed = unsafe { BorrowedFd::borrow_raw(fd) };
458 let pool = shm.create_pool(borrowed, total as i32, qh, ());
459 let buffers = (0..RING)
460 .map(|i| {
461 pool.create_buffer(
462 (i * frame_bytes) as i32,
463 w,
464 h,
465 stride,
466 wl_shm::Format::Xrgb8888,
467 qh,
468 (),
469 )
470 })
471 .collect();
472 unsafe { libc::close(fd) };
473 pool.destroy();
474 Ok((map, frame_bytes, buffers))
475}
476
477/// Ring de `RING` buffers **dmabuf** (gbm LINEAR, XRGB8888) de `w×h`, envueltos
478/// como `wl_buffer` por `zwp_linux_dmabuf`. A diferencia del shm, son
479/// scanout-capable: mirada los puede promover a un plano de overlay del DRM.
480/// Devuelve el `Ring::Dmabuf` (retiene device+bos vivos) y los `wl_buffer`.
481fn crear_ring_dmabuf(
482 dmabuf: &ZwpLinuxDmabufV1,
483 qh: &QueueHandle<WlGlobals>,
484 w: i32,
485 h: i32,
486) -> Result<(Ring, Vec<WlBuffer>), String> {
487 // Nodo de render de la GPU: el mismo Intel que usa mirada, así el buffer
488 // LINEAR es importable para composición y escaneable en un plano.
489 let file = std::fs::OpenOptions::new()
490 .read(true)
491 .write(true)
492 .open("/dev/dri/renderD128")
493 .map_err(|e| format!("abrir renderD128: {e}"))?;
494 let device = GbmDevice::new(file).map_err(|e| format!("gbm device: {e}"))?;
495
496 // SIN `SCANOUT`: un render node (renderD128) no puede asignar buffers de
497 // scanout (EACCES — eso es del card node con DRM master, que tiene mirada).
498 // No hace falta: mirada importa el dmabuf y forja SU framebuffer en card1; lo
499 // que lo hace escaneable es el modifier **LINEAR**, no el flag del cliente.
500 // `WRITE` da EINVAL en Intel (es para gbm_bo_write, no para map); para mapear
501 // por CPU basta `LINEAR`. Probamos combos en orden hasta que uno asigne.
502 let combos: [(&str, BufferObjectFlags); 3] = [
503 ("LINEAR|RENDERING", BufferObjectFlags::LINEAR | BufferObjectFlags::RENDERING),
504 ("LINEAR", BufferObjectFlags::LINEAR),
505 ("RENDERING", BufferObjectFlags::RENDERING),
506 ];
507 let fourcc = GbmFormat::Xrgb8888 as u32;
508
509 let mut bos: Vec<BufferObject<()>> = Vec::with_capacity(RING);
510 let mut buffers: Vec<WlBuffer> = Vec::with_capacity(RING);
511 for i in 0..RING {
512 let bo = {
513 let mut hecho: Option<BufferObject<()>> = None;
514 let mut ultimo = String::new();
515 for (nombre, flags) in combos {
516 match device.create_buffer_object::<()>(w as u32, h as u32, GbmFormat::Xrgb8888, flags) {
517 Ok(b) => {
518 if i == 0 {
519 eprintln!("[spike] gbm flags = {nombre}");
520 }
521 hecho = Some(b);
522 break;
523 }
524 Err(e) => ultimo = format!("{nombre}: {e}"),
525 }
526 }
527 hecho.ok_or_else(|| format!("gbm bo[{i}] (ningún combo): {ultimo}"))?
528 };
529 let fd = bo.fd().map_err(|_| format!("gbm bo[{i}].fd inválido"))?;
530 let modifier: u64 = bo.modifier().into();
531 let params = dmabuf.create_params(qh, ());
532 params.add(
533 fd.as_fd(),
534 0,
535 bo.offset(0),
536 bo.stride(),
537 (modifier >> 32) as u32,
538 (modifier & 0xffff_ffff) as u32,
539 );
540 // create_immed (v2+): sin roundtrip. El compositor dupea el fd; si el
541 // formato/modifier no le sirve manda un protocol error (lo veríamos).
542 let buffer = params.create_immed(
543 w,
544 h,
545 fourcc,
546 zwp_linux_buffer_params_v1::Flags::empty(),
547 qh,
548 (),
549 );
550 params.destroy();
551 drop(fd); // ya dupeado en create_immed; el bo mantiene el buffer real.
552 if i == 0 {
553 eprintln!(
554 "[spike] dmabuf bo {w}x{h} stride={} offset={} modifier={modifier:#x} fourcc={fourcc:#x}",
555 bo.stride(),
556 bo.offset(0),
557 );
558 }
559 bos.push(bo);
560 buffers.push(buffer);
561 }
562 Ok((Ring::Dmabuf { _device: device, bos }, buffers))
563}
564
565/// Buffer shm de color sólido (modo sin archivo).
566fn crear_buffer_solido(
567 shm: &WlShm,
568 qh: &QueueHandle<WlGlobals>,
569 w: i32,
570 h: i32,
571) -> Result<WlBuffer, String> {
572 let stride = w * 4;
573 let size = (stride * h) as usize;
574 let (fd, map) = memfd_map(size)?;
575 let px = map as *mut u32;
576 for i in 0..(w * h) as isize {
577 unsafe { px.offset(i).write(0x00FF_7800) }; // naranja
578 }
579 let borrowed = unsafe { BorrowedFd::borrow_raw(fd) };
580 let pool = shm.create_pool(borrowed, size as i32, qh, ());
581 let buffer = pool.create_buffer(0, w, h, stride, wl_shm::Format::Xrgb8888, qh, ());
582 unsafe { libc::close(fd) };
583 let _ = map;
584 pool.destroy();
585 Ok(buffer)
586}
587
588// ---------------------------------------------------------------------------
589// UI winit + wgpu (limpia el fondo; el "hueco" = fondo transparente)
590// ---------------------------------------------------------------------------
591
592struct Gpu {
593 surface: wgpu::Surface<'static>,
594 device: wgpu::Device,
595 queue: wgpu::Queue,
596 config: wgpu::SurfaceConfiguration,
597}
598
599impl Gpu {
600 fn new(window: Arc<Window>) -> Result<Self, String> {
601 let instance = wgpu::Instance::new(&wgpu::InstanceDescriptor {
602 backends: wgpu::Backends::PRIMARY,
603 ..Default::default()
604 });
605 let surface = instance
606 .create_surface(window.clone())
607 .map_err(|e| format!("create_surface: {e}"))?;
608 let adapter = pollster::block_on(instance.request_adapter(&wgpu::RequestAdapterOptions {
609 power_preference: wgpu::PowerPreference::LowPower,
610 force_fallback_adapter: false,
611 compatible_surface: Some(&surface),
612 }))
613 .map_err(|e| format!("request_adapter: {e}"))?;
614 let (device, queue) = pollster::block_on(adapter.request_device(&wgpu::DeviceDescriptor {
615 label: Some("video-plane-spike"),
616 required_features: wgpu::Features::empty(),
617 required_limits: wgpu::Limits::default(),
618 memory_hints: wgpu::MemoryHints::default(),
619 experimental_features: wgpu::ExperimentalFeatures::default(),
620 trace: wgpu::Trace::Off,
621 }))
622 .map_err(|e| format!("request_device: {e}"))?;
623
624 let caps = surface.get_capabilities(&adapter);
625 let format = caps
626 .formats
627 .iter()
628 .copied()
629 .find(|f| matches!(f, wgpu::TextureFormat::Bgra8Unorm | wgpu::TextureFormat::Rgba8Unorm))
630 .unwrap_or(caps.formats[0]);
631 let alpha_mode = caps
632 .alpha_modes
633 .iter()
634 .copied()
635 .find(|a| matches!(a, wgpu::CompositeAlphaMode::PreMultiplied))
636 .unwrap_or(caps.alpha_modes[0]);
637 eprintln!("[spike] wgpu formato={format:?} alpha={alpha_mode:?}");
638 let size = window.inner_size();
639 let config = wgpu::SurfaceConfiguration {
640 usage: wgpu::TextureUsages::RENDER_ATTACHMENT,
641 format,
642 width: size.width.max(1),
643 height: size.height.max(1),
644 present_mode: wgpu::PresentMode::Fifo,
645 desired_maximum_frame_latency: 2,
646 alpha_mode,
647 view_formats: vec![],
648 };
649 surface.configure(&device, &config);
650 Ok(Self { surface, device, queue, config })
651 }
652
653 fn resize(&mut self, w: u32, h: u32) {
654 self.config.width = w.max(1);
655 self.config.height = h.max(1);
656 self.surface.configure(&self.device, &self.config);
657 }
658
659 fn render(&self, transparent: bool) {
660 let frame = match self.surface.get_current_texture() {
661 Ok(f) => f,
662 Err(_) => return,
663 };
664 let view = frame
665 .texture
666 .create_view(&wgpu::TextureViewDescriptor::default());
667 let mut enc = self
668 .device
669 .create_command_encoder(&wgpu::CommandEncoderDescriptor { label: None });
670 let clear = if transparent {
671 wgpu::Color { r: 0.0, g: 0.0, b: 0.0, a: 0.0 }
672 } else {
673 wgpu::Color { r: 0.05, g: 0.07, b: 0.12, a: 1.0 }
674 };
675 {
676 let _pass = enc.begin_render_pass(&wgpu::RenderPassDescriptor {
677 label: None,
678 color_attachments: &[Some(wgpu::RenderPassColorAttachment {
679 view: &view,
680 resolve_target: None,
681 depth_slice: None,
682 ops: wgpu::Operations {
683 load: wgpu::LoadOp::Clear(clear),
684 store: wgpu::StoreOp::Store,
685 },
686 })],
687 depth_stencil_attachment: None,
688 timestamp_writes: None,
689 occlusion_query_set: None,
690 });
691 }
692 self.queue.submit(Some(enc.finish()));
693 frame.present();
694 }
695}
696
697// ---------------------------------------------------------------------------
698// App
699// ---------------------------------------------------------------------------
700
701struct SpikeApp {
702 /// Fuente de video ya abierta (o None para el cuadro sólido). Se consume al
703 /// crear el plano.
704 pending_video: Option<(Box<dyn FrameSource + Send>, u32, u32, f32)>,
705 win_size: (u32, u32),
706 window: Option<Arc<Window>>,
707 gpu: Option<Gpu>,
708 plane: Option<VideoPlane>,
709 transparent: bool,
710 interval: Option<Duration>,
711}
712
713impl ApplicationHandler for SpikeApp {
714 fn resumed(&mut self, el: &ActiveEventLoop) {
715 if self.window.is_some() {
716 return;
717 }
718 let attrs = Window::default_attributes()
719 .with_title("llimphi-video-plane · subsurface spike")
720 .with_transparent(true)
721 .with_inner_size(winit::dpi::LogicalSize::new(self.win_size.0, self.win_size.1));
722 let window = Arc::new(el.create_window(attrs).expect("create_window"));
723
724 let gpu = match Gpu::new(window.clone()) {
725 Ok(g) => g,
726 Err(e) => {
727 eprintln!("[spike] GPU falló: {e}");
728 el.exit();
729 return;
730 }
731 };
732 // Primer frame: crea/commitea el padre antes de atachar el subsurface.
733 gpu.render(self.transparent);
734
735 let video = self.pending_video.take();
736 match unsafe { VideoPlane::atachar(&window, video) } {
737 Ok(p) => {
738 self.interval = p.frame_interval();
739 eprintln!("[spike] subsurface OK. B=abajo/arriba, T=transparente, ESC=salir.");
740 self.plane = Some(p);
741 }
742 Err(e) => eprintln!("[spike] atachar subsurface FALLÓ: {e}"),
743 }
744
745 self.gpu = Some(gpu);
746 self.window = Some(window);
747 // NO pedimos redraw: el padre es un host transparente estático. Redibujarlo
748 // por frame bloquea el loop en `present` (Fifo) cuando mirada no manda
749 // frame-callbacks (ventana no visible). El video lo bombea `about_to_wait`
750 // (Poll) sobre el subsurface, que commitea independiente (desync).
751 }
752
753 fn window_event(&mut self, el: &ActiveEventLoop, _id: WindowId, event: WindowEvent) {
754 match event {
755 WindowEvent::CloseRequested => el.exit(),
756 WindowEvent::Resized(size) => {
757 if let Some(g) = &mut self.gpu {
758 g.resize(size.width, size.height);
759 }
760 if let Some(w) = &self.window {
761 w.request_redraw();
762 }
763 }
764 WindowEvent::RedrawRequested => {
765 // Sólo si el compositor lo pide (frame-callback real): repintamos el
766 // padre. NO re-pedimos redraw (evita el bloqueo en `present`).
767 if let Some(g) = &self.gpu {
768 g.render(self.transparent);
769 }
770 }
771 WindowEvent::KeyboardInput {
772 event: KeyEvent { logical_key, state: ElementState::Pressed, .. },
773 ..
774 } => match logical_key {
775 Key::Named(NamedKey::Escape) => el.exit(),
776 Key::Character(c) if c.eq_ignore_ascii_case("b") => {
777 if let Some(p) = &mut self.plane {
778 let below = !p.below;
779 p.set_below(below);
780 eprintln!("[spike] subsurface {}", if below { "DEBAJO" } else { "ENCIMA" });
781 }
782 if let Some(w) = &self.window {
783 w.request_redraw();
784 }
785 }
786 Key::Character(c) if c.eq_ignore_ascii_case("t") => {
787 self.transparent = !self.transparent;
788 eprintln!("[spike] fondo {}", if self.transparent { "TRANSPARENTE" } else { "opaco" });
789 if let Some(w) = &self.window {
790 w.request_redraw();
791 }
792 }
793 _ => {}
794 },
795 _ => {}
796 }
797 }
798
799 /// Despertado por el hilo timer (via `EventLoopProxy::send_event`) a cadencia
800 /// de video: bombea un frame al subsurface. Este es el ÚNICO driver del pump —
801 /// ni el `WaitUntil` ni los frame-callbacks del padre despiertan el loop bajo
802 /// mirada, pero `send_event` sí. Mono-core, sin busy-loop → el decoder respira.
803 fn user_event(&mut self, _el: &ActiveEventLoop, _event: ()) {
804 if let Some(p) = &mut self.plane {
805 p.pump(Instant::now());
806 }
807 }
808}
809
810/// Fuente **sintética**: patrón animado (tablero diagonal en movimiento +
811/// gradiente) a `w×h`. Decoupla la medición del pipeline dmabuf de foreign-av,
812/// que se estanca al manejarlo sin el sistema de clock/sync de media-app. Emite
813/// un frame por tick (el hilo timer del loop pacea la cadencia real).
814struct SyntheticSource {
815 w: u32,
816 h: u32,
817 t: f32,
818}
819
820impl FrameSource for SyntheticSource {
821 fn tick(&mut self, dt: Duration, buf: &mut Vec<u8>) -> Option<(u32, u32)> {
822 self.t += dt.as_secs_f32();
823 let (w, h) = (self.w as usize, self.h as usize);
824 buf.resize(w * h * 4, 0);
825 let phase = (self.t * 120.0) as i32; // scroll ~120 px/s
826 for y in 0..h {
827 let gy = ((y * 255) / h) as u8;
828 for x in 0..w {
829 let band = (((x as i32 + y as i32 + phase) / 32) & 1) as u8;
830 let i = (y * w + x) * 4;
831 buf[i] = if band == 0 { 40 } else { 220 };
832 buf[i + 1] = ((x * 255) / w) as u8;
833 buf[i + 2] = gy;
834 buf[i + 3] = 255;
835 }
836 }
837 Some((self.w, self.h))
838 }
839}
840
841/// Abre `path` por foreign-av y arma un `FrameSource` + dims + fps.
842fn abrir_video(path: &str) -> Result<(Box<dyn FrameSource + Send>, u32, u32, f32), String> {
843 let info = foreign_av::probe(path).map_err(|e| format!("probe: {e:?}"))?;
844 let session = foreign_av::MediaSession::open(info).map_err(|e| format!("open: {e:?}"))?;
845 let src = foreign_av::FfmpegVideoSource::from_session(session)
846 .map_err(|e| format!("video source: {e:?}"))?;
847 let (w, h) = src.dimensions();
848 let fps = src.fps();
849 Ok((Box::new(src), w, h, fps))
850}
851
852fn main() {
853 let arg = std::env::args().nth(1);
854 let (pending_video, win_size) = match arg.as_deref() {
855 // Modo medición: fuente sintética a 30 fps, sin foreign-av.
856 Some("test") => {
857 let (w, h, fps) = (1024u32, 576u32, 30.0f32);
858 let src: Box<dyn FrameSource + Send> = Box::new(SyntheticSource { w, h, t: 0.0 });
859 eprintln!("[spike] modo TEST: fuente sintética {w}x{h} @ {fps}fps");
860 (Some((src, w, h, fps)), (w, h))
861 }
862 Some(path) => match abrir_video(path) {
863 Ok(v) => {
864 let (_, w, h, _) = &v;
865 let win = ((*w).min(1600).max(320), (*h).min(900).max(240));
866 (Some(v), win)
867 }
868 Err(e) => {
869 eprintln!("[spike] no pude abrir {path}: {e} — caigo a cuadro sólido");
870 (None, DEFAULT_WIN)
871 }
872 },
873 None => {
874 eprintln!("[spike] sin archivo → cuadro sólido. Uso: subsurface-spike <video>");
875 (None, DEFAULT_WIN)
876 }
877 };
878
879 // Intervalo del video → cadencia del hilo timer que despierta el loop.
880 let interval = pending_video.as_ref().map(|(_, _, _, fps)| {
881 let f = if fps.is_finite() && *fps > 1.0 { *fps } else { 30.0 };
882 Duration::from_secs_f32(1.0 / f)
883 });
884
885 let el = EventLoop::<()>::with_user_event()
886 .build()
887 .expect("event loop");
888 // Wait: el loop bloquea entre despertadas (sin quemar CPU). Lo despierta el
889 // hilo timer via send_event, no el WaitUntil interno (que no dispara aquí).
890 el.set_control_flow(ControlFlow::Wait);
891 // Hilo timer: reloj de pared que patea el loop a cadencia de video. Único
892 // mecanismo que despierta winit bajo mirada de forma fiable.
893 if let Some(iv) = interval {
894 let proxy = el.create_proxy();
895 std::thread::spawn(move || loop {
896 std::thread::sleep(iv);
897 if proxy.send_event(()).is_err() {
898 break; // el loop murió → salimos.
899 }
900 });
901 }
902
903 let mut app = SpikeApp {
904 pending_video,
905 win_size,
906 window: None,
907 gpu: None,
908 plane: None,
909 transparent: false,
910 interval,
911 };
912 el.run_app(&mut app).expect("run_app");
913}