mpv-engine 0.2.0

Toolkit-agnostic libmpv embedding core: handle lifecycle, commands, typed events, and render seams.
Documentation
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
872
873
874
875
876
877
878
879
880
881
882
883
884
885
886
887
888
889
890
891
892
893
894
895
896
897
898
899
900
901
902
903
904
905
906
907
908
909
910
911
912
913
914
915
916
917
918
919
920
921
922
923
924
925
926
927
928
929
930
931
932
933
934
935
936
937
938
939
940
941
942
943
944
945
946
947
948
949
950
951
//! Exported-frame render backend (`export` feature, macOS + Linux +
//! Windows): the engine owns a hidden GL context on a dedicated render
//! thread — CGL on macOS, surfaceless EGL over a DRM render node on
//! Linux, WGL on an invisible window on Windows — mpv renders into
//! exportable framebuffers there (IOSurface-backed / DMA-BUF-backed /
//! shared-D3D11-texture-backed), and the shell receives zero-copy
//! [`ExportedFrame`] handles it imports into Metal / Vulkan / D3D (or
//! wgpu) directly — no pixel ever crosses the CPU, and no GL leaks into
//! the shell.
//!
//! Why this shape: libmpv's render API speaks only OpenGL and software,
//! and mpv never inspects what memory backs the FBO it is handed — so
//! the consumer-side fix for "no Metal/Vulkan/D3D render API" is to make
//! the FBO's color attachment *born exportable* (an IOSurface, a
//! DMA-BUF, a shared DXGI resource) and hand the handle across. The GL
//! involvement is confined to this module's thread; the shell's
//! compositor never touches it.
//!
//! Pool discipline: a small ring of buffers (default 3) rotates through
//! free → rendering → published → in-use(shell) → free. mpv renders into
//! a free buffer, the platform's publish barrier makes it coherent for
//! other APIs (`glFlush` under IOSurface's coherency contract on macOS,
//! `glFinish` on Linux, `glFinish` plus the interop unlock on Windows —
//! see each platform module), and the newest published frame replaces an
//! unconsumed older one — the shell always acquires the latest frame.
//! While the shell holds every buffer, frames are dropped, not queued.

#[cfg(target_os = "linux")]
mod linux;
#[cfg(target_os = "linux")]
use linux as platform;
#[cfg(target_os = "macos")]
mod macos;
#[cfg(target_os = "macos")]
use macos as platform;
// `self::` is load-bearing on the Windows arm: the `wgpu` feature pulls
// the `windows` crate into the extern prelude, and a bare `use windows`
// here would be ambiguous with it.
#[cfg(target_os = "windows")]
mod windows;
#[cfg(target_os = "windows")]
use self::windows as platform;
#[cfg(all(feature = "wgpu", target_os = "linux"))]
mod wgpu_linux;
#[cfg(all(feature = "wgpu", target_os = "macos"))]
mod wgpu_macos;
#[cfg(all(feature = "wgpu", target_os = "windows"))]
mod wgpu_windows;

use std::ffi::c_void;
use std::sync::Arc;
use std::sync::atomic::{AtomicBool, AtomicU64, Ordering};
use std::thread::JoinHandle;

use parking_lot::{Condvar, Mutex};
use rsmpv::Mpv;
use rsmpv::render::{OpenGlFbo, OwnedRenderContext};

use crate::error::{Error, Result};
use platform::SurfaceBuffer;

/// GL enum values shared by the backends' hidden-context FBO plumbing.
/// The three platforms load GL differently (link-time on macOS,
/// proc-address on Linux/Windows), so their `extern` declarations stay
/// local — but these constants are the same numbers everywhere and live
/// here once. Platform-specific enums (e.g. macOS's `GL_TEXTURE_RECTANGLE`)
/// stay in their own module.
// Each platform imports only the subset it needs; the rest are unused on
// that build, which is expected for a shared enum table.
#[allow(dead_code)]
mod gl_consts {
    pub(crate) const GL_TEXTURE_2D: u32 = 0x0DE1;
    pub(crate) const GL_TEXTURE_MIN_FILTER: u32 = 0x2801;
    pub(crate) const GL_TEXTURE_MAG_FILTER: u32 = 0x2800;
    pub(crate) const GL_NEAREST: i32 = 0x2600;
    pub(crate) const GL_RGBA8: u32 = 0x8058;
    pub(crate) const GL_FRAMEBUFFER: u32 = 0x8D40;
    pub(crate) const GL_COLOR_ATTACHMENT0: u32 = 0x8CE0;
    pub(crate) const GL_FRAMEBUFFER_COMPLETE: u32 = 0x8CD5;
}

/// The contract every platform backend satisfies — the normative spec for
/// the `cfg`-selected `platform` module (`use linux as platform`), split
/// across an owning GL context ([`ExportBackend`]) and the exportable
/// buffers it renders into ([`ExportBuffer`]). Only one implementation
/// ever compiles, so these traits exist for documentation and a
/// compile-time check that each backend matches the shape `mod.rs` drives
/// — not for runtime polymorphism.
///
/// # Thread discipline (part of the contract)
/// [`ExportBackend::new`], [`make_current`](ExportBackend::make_current),
/// [`begin_render`](ExportBackend::begin_render),
/// [`publish_barrier`](ExportBackend::publish_barrier),
/// [`ExportBuffer::new`] and [`delete_gl`](ExportBuffer::delete_gl) run
/// **only on the export render thread**, with the context current — that
/// is what lets each implementation uphold GL's thread rules by
/// construction. [`copy_pixels`](ExportBuffer::copy_pixels) may be called
/// from any thread (it must not touch GL). `Drop` may run on any thread
/// once the GL names are gone.
pub(crate) trait ExportBackend: Sized {
    /// The exportable buffers this backend allocates and renders into.
    type Buffer: ExportBuffer<Backend = Self>;

    /// The FBO color format reported to mpv
    /// (`OpenGlFbo::internal_format`). A hint only — the real storage
    /// layout is fixed by the exportable memory's own format.
    const FBO_INTERNAL_FORMAT: i32;

    /// Bring up the hidden, surfaceless GL context on the render thread.
    fn new() -> Result<Self>;

    /// Make the context current on the calling (render) thread.
    fn make_current(&self) -> Result<()>;

    /// Hook run immediately before mpv renders into `buffer`'s FBO —
    /// where a backend hands its exportable storage to GL. A no-op on
    /// macOS/Linux (the storage is always GL's to write); on Windows it
    /// takes the `WGL_NV_DX_interop2` lock.
    fn begin_render(&self, buffer: &Self::Buffer);

    /// Barrier guaranteeing every GPU write to `buffer` is complete — and
    /// its exportable storage back in the owning API's hands — before the
    /// frame is published to a consumer. `glFlush` (macOS), `glFinish`
    /// (Linux), or `glFinish` plus the interop unlock (Windows).
    fn publish_barrier(&self, buffer: &Self::Buffer);

    /// Resolve a GL entry point for mpv's loader.
    fn proc_address(name: &str) -> *mut c_void;
}

/// A "born exportable" color buffer: renderable as a GL FBO color
/// attachment *and* exportable as a cross-API/cross-process handle. The
/// platform-native export accessor (IOSurface / dmabuf fd / shared
/// handle) is inherent to the concrete type — it differs in name and
/// shape per platform, so it is deliberately **not** part of this common
/// contract; only the lifecycle `mod.rs` drives is.
pub(crate) trait ExportBuffer: Sized {
    /// The context that allocates and tears these down.
    type Backend: ExportBackend<Buffer = Self>;

    /// Allocate a `width`×`height` exportable buffer. Render thread,
    /// context current.
    fn new(gl: &Self::Backend, width: u32, height: u32) -> Result<Self>;

    /// The GL FBO name mpv renders into.
    fn fbo(&self) -> u32;

    /// `(width, height)` in pixels.
    fn size(&self) -> (u32, u32);

    /// Delete the GL-side names (render thread, context current). The
    /// backing export memory is released by `Drop`; a buffer retired off
    /// the render thread simply skips this and lets its names die with
    /// the context.
    fn delete_gl(&mut self, gl: &Self::Backend);

    /// CPU readback as tightly packed BGRA8, row 0 at the top. Any
    /// thread; must not touch GL.
    fn copy_pixels(&self) -> Vec<u8>;
}

/// Attach-time configuration for
/// [`Engine::attach_exported_render`](crate::Engine::attach_exported_render).
///
/// Non-exhaustive so future knobs (pixel format, colorspace) stay
/// additive — construct with [`new`](Self::new) plus the chainable
/// setters.
#[derive(Debug, Clone, Copy)]
#[non_exhaustive]
pub struct ExportOptions {
    /// Initial frame width in pixels
    /// ([`Engine::set_export_size`](crate::Engine::set_export_size)
    /// changes it later).
    pub width: u32,
    /// Initial frame height in pixels.
    pub height: u32,
    /// Buffers in rotation (minimum 2, default 3): one being rendered,
    /// one published, one in the shell's hands. Raise it only if the
    /// shell legitimately holds several frames at once (e.g. frames in
    /// flight across a deep GPU pipeline).
    pub pool_size: usize,
}

impl ExportOptions {
    /// Options for `width`×`height` frames with the default pool of 3.
    pub fn new(width: u32, height: u32) -> Self {
        Self {
            width,
            height,
            pool_size: 3,
        }
    }

    /// Set [`pool_size`](field@Self::pool_size) (clamped to at least 2),
    /// chainable from [`new`](Self::new).
    #[must_use]
    pub fn pool_size(mut self, pool_size: usize) -> Self {
        self.pool_size = pool_size.max(2);
        self
    }
}

fn pack_size(width: u32, height: u32) -> u64 {
    (u64::from(width) << 32) | u64::from(height)
}

fn unpack_size(packed: u64) -> (u32, u32) {
    ((packed >> 32) as u32, packed as u32)
}

/// State shared by the engine's backend handle, the render thread, and
/// every outstanding [`ExportedFrame`].
pub(crate) struct ExportShared {
    state: Mutex<PoolState>,
    /// Wakes the render thread; every flag below is checked under
    /// `state`'s lock in its wait loop.
    cond: Condvar,
    shutdown: AtomicBool,
    /// An mpv update callback fired since the last service.
    update_pending: AtomicBool,
    /// Render a frame even without a new-frame update — set on resize so
    /// a paused/ended video re-renders at the new size.
    force_render: AtomicBool,
    /// A frame was `presented()`; forward `report_swap` to mpv.
    swap_pending: AtomicBool,
    /// A wanted render was dropped because no pool buffer was available
    /// (shell holding all of them, or allocation failed). Checked when a
    /// buffer comes back ([`return_buffer`] / [`retire_buffer`]), which
    /// re-arms `force_render` — so a `set_export_size` landing during
    /// pool exhaustion still re-renders once a buffer frees up, instead
    /// of leaving a paused/ended video stale at the old size forever.
    render_dropped: AtomicBool,
    /// Current target size, `(w << 32) | h`. Applied from the next
    /// rendered frame; stale-size pool buffers are retired lazily.
    target_size: AtomicU64,
    pool_size: usize,
}

#[derive(Default)]
struct PoolState {
    /// Buffers ready to render into (possibly of a stale size — the
    /// render thread retires those when it next needs a buffer).
    free: Vec<SurfaceBuffer>,
    /// The newest rendered frame the shell has not acquired yet.
    published: Option<SurfaceBuffer>,
    /// Frames currently in the shell's hands.
    in_use: usize,
    /// Buffers alive in total (`free` + `published` + `in_use`) — the
    /// pool-size cap counts all of them. Buffers in `retired` have
    /// already left the count.
    live: usize,
    /// Buffers permanently out of the pool (the Linux wgpu import
    /// consumes them — see [`retire_buffer`]), parked here for the
    /// render thread to delete their GL names on its next wake. Usually
    /// empty on macOS and Windows, whose imports return the buffer
    /// instead; a resize still retires wrong-size buffers everywhere.
    retired: Vec<SurfaceBuffer>,
}

impl ExportShared {
    fn notify(&self) {
        // Lock-then-notify so a render thread between its flag checks and
        // its wait can't miss the signal.
        let _state = self.state.lock();
        self.cond.notify_all();
    }

    pub(crate) fn set_target_size(&self, width: u32, height: u32) {
        self.target_size
            .store(pack_size(width, height), Ordering::SeqCst);
        self.force_render.store(true, Ordering::SeqCst);
        self.notify();
    }

    /// A published frame is waiting to be acquired. Used by attach to
    /// re-fire `on_update` for a frame published before the backend
    /// landed in the engine's render slot.
    pub(crate) fn has_published(&self) -> bool {
        self.state.lock().published.is_some()
    }

    /// A buffer became available again: if a wanted render was dropped
    /// for lack of one, re-arm the forced render now.
    fn rearm_dropped_render(&self) {
        if self.render_dropped.swap(false, Ordering::SeqCst) {
            self.force_render.store(true, Ordering::SeqCst);
            self.notify();
        }
    }
}

/// The engine-side handle stored in the render slot: shared state plus
/// the join handle. Dropping it (detach, or engine drop) shuts the
/// render thread down and joins it — which is exactly the "free the
/// render context with its GL context current" teardown, performed on
/// the one thread where that context is current.
pub(crate) struct ExportedRender {
    shared: Arc<ExportShared>,
    thread: Option<JoinHandle<()>>,
}

impl ExportedRender {
    /// Spawn the render thread and wait for its GL context + mpv render
    /// context to come up; `Err` means nothing was attached (the thread
    /// has already exited).
    pub(crate) fn create(
        core: Arc<Mpv>,
        options: ExportOptions,
        relay: impl Fn() + Send + Sync + 'static,
    ) -> Result<Self> {
        let shared = Arc::new(ExportShared {
            state: Mutex::new(PoolState::default()),
            cond: Condvar::new(),
            shutdown: AtomicBool::new(false),
            update_pending: AtomicBool::new(false),
            force_render: AtomicBool::new(false),
            swap_pending: AtomicBool::new(false),
            render_dropped: AtomicBool::new(false),
            target_size: AtomicU64::new(pack_size(options.width, options.height)),
            pool_size: options.pool_size.max(2),
        });
        let (init_tx, init_rx) = std::sync::mpsc::channel();
        let thread_shared = Arc::clone(&shared);
        let thread = std::thread::Builder::new()
            .name("mpv-export-render".into())
            .spawn(move || render_thread(core, thread_shared, Box::new(relay), init_tx))
            .map_err(|e| Error::ExportSetup(format!("spawning render thread: {e}")))?;
        match init_rx.recv() {
            Ok(Ok(())) => Ok(Self {
                shared,
                thread: Some(thread),
            }),
            Ok(Err(e)) => {
                let _ = thread.join();
                Err(e)
            }
            Err(_) => {
                let _ = thread.join();
                Err(Error::ExportSetup("render thread died during setup".into()))
            }
        }
    }

    pub(crate) fn shared(&self) -> &Arc<ExportShared> {
        &self.shared
    }

    /// Whether the calling thread *is* this backend's render thread —
    /// i.e. we are inside `on_update` (or code it called). Drives the
    /// two teardown paths below and
    /// [`Engine::detach_render`](crate::Engine::detach_render)'s lock
    /// choice.
    pub(crate) fn is_render_thread(&self) -> bool {
        self.thread
            .as_ref()
            .is_some_and(|t| t.thread().id() == std::thread::current().id())
    }

    /// Detach and hand over the render thread's `JoinHandle`.
    /// [`Engine::detach_render`](crate::Engine::detach_render) takes it
    /// on the self path (a detach from inside `on_update`, where the
    /// drop below cannot join) and parks it, so a later engine call from
    /// another thread joins the deferred teardown instead of leaving it
    /// to race process exit.
    pub(crate) fn take_thread(&mut self) -> Option<JoinHandle<()>> {
        self.thread.take()
    }
}

impl Drop for ExportedRender {
    fn drop(&mut self) {
        self.shared.shutdown.store(true, Ordering::SeqCst);
        self.shared.notify();
        if let Some(thread) = self.thread.take() {
            if thread.thread().id() == std::thread::current().id() {
                // Dropped from inside the render thread's own callback:
                // joining would self-deadlock (EDEADLK, a panic inside
                // Drop). Detach the handle instead; the shutdown flag is
                // already set, so the thread runs its normal teardown
                // and exits as soon as the callback returns. The GL
                // context is freed on the thread it is current on either
                // way. Only the drop-last-Engine-from-`on_update` case
                // still lands here — a self `detach_render` extracts the
                // handle first (`take_thread`) and parks it for a later
                // join.
                return;
            }
            let _ = thread.join();
        }
    }
}

/// The render thread: owns the hidden GL context (CGL / EGL) and the
/// mpv render context for their whole lives, so GL-currency is a
/// per-thread invariant here rather than a caller obligation — this is
/// what lets the public attach be a safe fn.
fn render_thread(
    core: Arc<Mpv>,
    shared: Arc<ExportShared>,
    relay: Box<dyn Fn() + Send + Sync>,
    init_tx: std::sync::mpsc::Sender<Result<()>>,
) {
    let gl = match platform::GlContext::new() {
        Ok(gl) => gl,
        Err(e) => {
            let _ = init_tx.send(Err(e));
            return;
        }
    };
    if let Err(e) = gl.make_current() {
        let _ = init_tx.send(Err(e));
        return;
    }
    // Advanced control on: this thread services `update()` promptly after
    // every callback by construction, and mpv gets direct rendering.
    let proc_address: Box<dyn FnMut(&str) -> *mut c_void + Send + 'static> =
        Box::new(platform::GlContext::proc_address);
    // SAFETY: the GL context above is current on this thread now and for
    // every later call — context and renderer live and die on this one
    // thread, with the renderer dropped first (declaration order below is
    // irrelevant; both drops happen before `render_thread` returns, ctx
    // explicitly before `gl`).
    let mut ctx = match unsafe { OwnedRenderContext::new_opengl(core, true, proc_address) } {
        Ok(ctx) => ctx,
        Err(e) => {
            let _ = init_tx.send(Err(e.into()));
            return;
        }
    };
    {
        let signal = Arc::clone(&shared);
        // Fires on mpv's internal threads (and once synchronously right
        // here): flag-and-wake only, per the render API's rules.
        ctx.set_update_callback(move || {
            signal.update_pending.store(true, Ordering::SeqCst);
            signal.notify();
        });
    }
    let _ = init_tx.send(Ok(()));

    loop {
        {
            let mut state = shared.state.lock();
            while !shared.shutdown.load(Ordering::SeqCst)
                && !shared.update_pending.load(Ordering::SeqCst)
                && !shared.force_render.load(Ordering::SeqCst)
                && !shared.swap_pending.load(Ordering::SeqCst)
                && state.retired.is_empty()
            {
                shared.cond.wait(&mut state);
            }
        }
        drain_retired(&shared, &gl);
        if shared.shutdown.load(Ordering::SeqCst) {
            break;
        }
        if shared.swap_pending.swap(false, Ordering::SeqCst) {
            ctx.report_swap();
        }
        let force = shared.force_render.swap(false, Ordering::SeqCst);
        let updated = shared.update_pending.swap(false, Ordering::SeqCst);
        // `update()` must run for every callback (advanced control); it
        // also gates rendering to real new-frame updates.
        let wants_frame = updated && ctx.update();
        if wants_frame || force {
            let retry_alloc = render_one(&mut ctx, &gl, &shared, &relay);
            if retry_alloc {
                // Buffer allocation failed with nothing outstanding (see
                // `render_one`): no return/retire will ever re-arm this
                // render, so re-arm it here and back off briefly — a
                // plain re-loop would spin hot on a persistent failure.
                // Any notify (shutdown, update, frame activity) cuts the
                // wait short.
                shared.force_render.store(true, Ordering::SeqCst);
                let mut state = shared.state.lock();
                let _ = shared
                    .cond
                    .wait_for(&mut state, std::time::Duration::from_millis(100));
            }
        }
    }

    // Teardown, still on this thread with the GL context current: pooled
    // buffers' GL names first, then the render context (the crate's
    // free-with-context-current contract), then the context itself.
    // Frames still in the shell's hands keep their backing memory alive
    // on their own (retained IOSurface / dmabuf fd); their GL names die
    // with the context.
    {
        let mut state = shared.state.lock();
        for mut buffer in state.free.drain(..) {
            buffer.delete_gl(&gl);
        }
        for mut buffer in state.retired.drain(..) {
            buffer.delete_gl(&gl);
        }
        if let Some(mut buffer) = state.published.take() {
            buffer.delete_gl(&gl);
        }
    }
    drop(ctx);
    drop(gl);
}

/// Delete the GL (and EGL / interop) names of buffers retired out of the
/// pool by other threads (see [`retire_buffer`]) and drop them. Render
/// thread only, GL context current; dropping also releases the buffer's
/// own reference on the memory — safe, because a retired buffer's memory
/// is pinned by the importer's.
fn drain_retired(shared: &ExportShared, gl: &platform::GlContext) {
    let retired = std::mem::take(&mut shared.state.lock().retired);
    for mut buffer in retired {
        buffer.delete_gl(gl);
    }
}

/// Render the current frame into a pool buffer and publish it. Skips
/// (dropping the frame) when the shell is holding every buffer — a
/// later buffer return re-arms the render (see `render_dropped`).
/// Returns `true` when the frame was dropped to a buffer-allocation
/// failure with **no frames outstanding**: there, no return/retire can
/// re-arm the render, so the caller must retry on a timer instead.
fn render_one(
    ctx: &mut OwnedRenderContext,
    gl: &platform::GlContext,
    shared: &Arc<ExportShared>,
    relay: &dyn Fn(),
) -> bool {
    let (width, height) = unpack_size(shared.target_size.load(Ordering::SeqCst));
    if width == 0 || height == 0 {
        return false;
    }
    let mut retry_alloc = false;
    let buffer = {
        let mut state = shared.state.lock();
        // Retire free buffers from before a resize (GL deletion is legal
        // here: this is the render thread, context current).
        let mut kept = Vec::with_capacity(state.free.len());
        for mut buffer in std::mem::take(&mut state.free) {
            if buffer.size() == (width, height) {
                kept.push(buffer);
            } else {
                buffer.delete_gl(gl);
                state.live -= 1;
            }
        }
        state.free = kept;
        if let Some(buffer) = state.free.pop() {
            Some(buffer)
        } else {
            // A published frame from before a resize is the wrong size —
            // useless to us and to the shell — so retire it up front.
            if let Some(mut stale) = state.published.take_if(|b| b.size() != (width, height)) {
                stale.delete_gl(gl);
                state.live -= 1;
            }
            // Grow the pool *before* reclaiming an unconsumed published
            // frame. Keeping that frame acquirable is what lets a
            // continuously-updating source actually reach the shell:
            // reclaim-first would have the render thread take its own
            // just-published buffer back for the next frame every tick,
            // beating the consumer's acquire (which carries thread
            // wakeup latency) to it — the pool would never grow past one
            // live buffer and the shell would starve (a black window on
            // real video). Newest-wins still holds: once at capacity the
            // published frame *is* reclaimed below, and `render_one`'s
            // publish step replaces an unconsumed frame regardless.
            if state.live < shared.pool_size {
                match SurfaceBuffer::new(gl, width, height) {
                    Ok(buffer) => {
                        state.live += 1;
                        Some(buffer)
                    }
                    Err(e) => {
                        tracing::warn!("export: buffer allocation failed: {e}");
                        // Fall back to the published frame if there is
                        // one; else there's nothing to render into.
                        if let Some(buffer) = state.published.take() {
                            Some(buffer)
                        } else if state.in_use == 0 {
                            // Nothing outstanding, so no return/retire
                            // will convert `render_dropped` back into a
                            // force — hand the retry to the render loop's
                            // timer instead.
                            retry_alloc = true;
                            None
                        } else {
                            shared.render_dropped.store(true, Ordering::SeqCst);
                            None
                        }
                    }
                }
            } else if let Some(buffer) = state.published.take() {
                // Pool at capacity: reclaim the unconsumed published
                // frame (newest-wins — the shell skipped it).
                Some(buffer)
            } else {
                tracing::debug!(
                    "export: shell holds all {} buffers; dropping frame",
                    shared.pool_size
                );
                // The consumed update/force flags would otherwise lose
                // this render for good on a paused/ended video; a buffer
                // return re-arms it (see `render_dropped`).
                shared.render_dropped.store(true, Ordering::SeqCst);
                None
            }
        }
    };
    let Some(buffer) = buffer else {
        return retry_alloc;
    };
    let fbo = OpenGlFbo {
        fbo: buffer.fbo() as i32,
        width: width as i32,
        height: height as i32,
        internal_format: platform::GlContext::FBO_INTERNAL_FORMAT,
    };
    // Hand the buffer's storage to GL (a no-op except on Windows, where
    // the interop lock is what makes the GL side's write legal) and give
    // it back in the publish barrier — unconditionally, so a failed
    // render can't leave a half-handed-over buffer in the pool.
    gl.begin_render(&buffer);
    // No flip: mpv's unflipped FBO output already puts row 0 at the top
    // of the image (pinned by the orientation integration tests on every
    // platform), which is the layout Metal/CoreVideo/Vulkan/D3D
    // consumers read; flip_y is for GL-convention targets.
    // Block-for-target-time is mpv's own pacing, harmless on this
    // dedicated thread.
    let rendered = ctx.render_opengl(fbo, false, true);
    gl.publish_barrier(&buffer);
    if let Err(e) = rendered {
        tracing::warn!("export render failed: {e}");
        shared.state.lock().free.push(buffer);
        return false;
    }
    {
        let mut state = shared.state.lock();
        if let Some(previous) = state.published.replace(buffer) {
            state.free.push(previous);
        }
    }
    relay();
    false
}

/// One rendered video frame, wrapping the exportable buffer the
/// engine's hidden GL context rendered into — a retained IOSurface on
/// macOS, a DMA-BUF on Linux, a shared D3D11 texture on Windows.
/// Acquired with
/// [`Engine::acquire_frame`](crate::Engine::acquire_frame), imported
/// into the shell's GPU API via the platform handle (`io_surface` on
/// macOS, `dma_buf_fd` on Linux, `shared_handle` on Windows).
///
/// Dropping the frame returns its buffer to the render pool, after which
/// **mpv will render future frames into the same memory** — hold the
/// frame until the GPU work sampling it has completed (e.g. until the
/// command buffer's completion handler / fence), not merely until it
/// was encoded. Importing the handle into Metal, Vulkan or D3D takes its
/// own reference on the memory, but that only keeps the memory alive; it
/// does not stop the pool reusing it for pixels.
///
/// With the `wgpu` feature, `into_wgpu_texture` removes that whole
/// obligation: wgpu's own completion tracking keeps the memory backing
/// the imported texture out of mpv's hands until wgpu has finished with
/// it, GPU work included (returned to the pool on macOS, retired from
/// it on Linux and Windows — invisible either way).
pub struct ExportedFrame {
    buffer: Option<SurfaceBuffer>,
    shared: Arc<ExportShared>,
}

impl ExportedFrame {
    pub(crate) fn take_published(shared: Arc<ExportShared>) -> Option<Self> {
        let mut state = shared.state.lock();
        let buffer = state.published.take()?;
        state.in_use += 1;
        drop(state);
        Some(Self {
            buffer: Some(buffer),
            shared,
        })
    }

    fn buffer(&self) -> &SurfaceBuffer {
        self.buffer
            .as_ref()
            .expect("buffer present until drop/presented")
    }

    /// The frame's retained `IOSurfaceRef`, valid while this frame is
    /// alive (see the type docs for the reuse hazard after drop). Pass it
    /// to `MTLDevice newTextureWithDescriptor:iosurface:plane:` (pixel
    /// format `bgra8Unorm`, plane 0) or your interop layer's equivalent.
    #[cfg(target_os = "macos")]
    pub fn io_surface(&self) -> *mut c_void {
        self.buffer().io_surface()
    }

    /// The frame's DMA-BUF fd, valid while this frame is alive (see the
    /// type docs for the reuse hazard after drop). A single-plane
    /// [`fourcc`](Self::fourcc) buffer with [`modifier`](Self::modifier)
    /// `DRM_FORMAT_MOD_LINEAR`, [`stride`](Self::stride) bytes per row,
    /// plane offset 0.
    ///
    /// Import it into Vulkan via `VK_EXT_external_memory_dma_buf`
    /// (`VK_FORMAT_B8G8R8A8_UNORM`, `VK_IMAGE_TILING_LINEAR`), into EGL
    /// via `EGL_EXT_image_dma_buf_import`, or across a compositor
    /// protocol — anything that speaks dmabuf. APIs that take fd
    /// *ownership* (Vulkan import does) must be handed a duplicate
    /// (`try_clone_to_owned`), never this fd.
    #[cfg(target_os = "linux")]
    pub fn dma_buf_fd(&self) -> std::os::fd::BorrowedFd<'_> {
        self.buffer().dma_buf_fd()
    }

    /// Bytes per row of the DMA-BUF's single plane (≥ `width * 4`).
    #[cfg(target_os = "linux")]
    pub fn stride(&self) -> u32 {
        self.buffer().stride()
    }

    /// The buffer's DRM fourcc: `DRM_FORMAT_ARGB8888` (`'AR24'`) — or
    /// `DRM_FORMAT_XRGB8888` (`'XR24'`) on drivers that couldn't render
    /// to the former, in which case the alpha byte is meaningless. Both
    /// are little-endian B,G,R,A/X bytes: `VK_FORMAT_B8G8R8A8_UNORM` /
    /// wgpu `Bgra8Unorm`.
    #[cfg(target_os = "linux")]
    pub fn fourcc(&self) -> u32 {
        self.buffer().fourcc()
    }

    /// The buffer's DRM format modifier — always
    /// `DRM_FORMAT_MOD_LINEAR` (0) with this backend, exposed so import
    /// code can pass it through instead of hardcoding.
    #[cfg(target_os = "linux")]
    pub fn modifier(&self) -> u64 {
        platform::MODIFIER_LINEAR
    }

    /// The frame's shared NT `HANDLE` for the backing D3D11 texture,
    /// valid while this frame is alive (see the type docs for the reuse
    /// hazard after drop). A single-subresource, non-mipmapped 2D
    /// texture in [`dxgi_format`](Self::dxgi_format).
    ///
    /// Open it with `ID3D11Device1::OpenSharedResource1`,
    /// `ID3D12Device::OpenSharedHandle`, or Vulkan's
    /// `VK_KHR_external_memory_win32`
    /// (`VK_EXTERNAL_MEMORY_HANDLE_TYPE_D3D11_TEXTURE_BIT`). All of
    /// those *duplicate* the handle internally, so — unlike the Linux
    /// dmabuf fd — nothing here takes ownership and no duplicate is
    /// needed. The texture deliberately carries **no keyed mutex** (that
    /// is what lets a plain importer, wgpu included, use it at all);
    /// ordering comes from the backend's publish barrier, which retires
    /// mpv's GPU work before the frame is ever handed out.
    #[cfg(target_os = "windows")]
    pub fn shared_handle(&self) -> *mut c_void {
        self.buffer().shared_handle()
    }

    /// The shared texture's DXGI format — always
    /// `DXGI_FORMAT_B8G8R8A8_UNORM` (87) with this backend, exposed so
    /// import code can pass it through instead of hardcoding. The same
    /// little-endian B,G,R,A bytes as the other platforms:
    /// `VK_FORMAT_B8G8R8A8_UNORM` / wgpu `Bgra8Unorm`.
    #[cfg(target_os = "windows")]
    pub fn dxgi_format(&self) -> u32 {
        platform::DXGI_FORMAT_BGRA8_UNORM
    }

    /// Frame width in pixels.
    pub fn width(&self) -> u32 {
        self.buffer().size().0
    }

    /// Frame height in pixels.
    pub fn height(&self) -> u32 {
        self.buffer().size().1
    }

    /// Copy the pixels out as tightly packed BGRA8 rows, row 0 at the
    /// top. This is a CPU readback — for screenshots, thumbnails, and
    /// tests, not the per-frame display path (that's what the zero-copy
    /// platform handle avoids).
    pub fn copy_pixels(&self) -> Vec<u8> {
        self.buffer().copy_pixels()
    }

    /// Consume the frame, telling mpv it was actually displayed
    /// (`mpv_render_context_report_swap` — feeds its frame-timing
    /// statistics). Optional: plain `drop` returns the buffer without
    /// the report.
    pub fn presented(self) {
        self.shared.swap_pending.store(true, Ordering::SeqCst);
        self.shared.notify();
        // Drop returns the buffer to the pool.
    }
}

impl Drop for ExportedFrame {
    fn drop(&mut self) {
        // `buffer` is `None` when ownership moved on — into the wgpu
        // drop callback (`into_wgpu_texture`), which does this return
        // itself once wgpu is finished with the texture.
        if let Some(buffer) = self.buffer.take() {
            return_buffer(&self.shared, buffer);
        }
    }
}

/// Return an in-use buffer to the pool: the one bookkeeping path shared
/// by [`ExportedFrame`]'s drop and the wgpu imports' drop callbacks
/// (macOS via Metal's `DropCallback`, Linux via the hand-rolled
/// `texture_from_raw` callback, Windows via `ReleaseKeeper`). Windows
/// still falls back to [`retire_buffer`] when it can't arm that callback.
/// After teardown the buffer just idles in `free` until the shared state
/// drops with it (GL names died with the context; the backing memory is
/// released by `SurfaceBuffer`'s own drop).
fn return_buffer(shared: &ExportShared, buffer: SurfaceBuffer) {
    {
        let mut state = shared.state.lock();
        state.in_use -= 1;
        state.free.push(buffer);
    }
    shared.rearm_dropped_render();
}

/// Permanently retire an in-use buffer from the pool — the Windows wgpu
/// import's fallback counterpart to [`return_buffer`], used only when it
/// can't arm the `ReleaseKeeper` completion callback: the buffer's memory
/// backs a wgpu texture that wgpu releases on its own schedule, so mpv
/// must never render into that memory again. Shrinking `live` lets the
/// render thread allocate a replacement; the buffer parks in `retired`
/// until the render thread deletes its GL names and releases our own
/// reference on the memory — the importer holds its own. (macOS and,
/// since issue #4's POC, Linux always reuse via a drop callback instead.)
#[cfg(all(feature = "wgpu", target_os = "windows"))]
fn retire_buffer(shared: &ExportShared, buffer: SurfaceBuffer) {
    {
        let mut state = shared.state.lock();
        state.in_use -= 1;
        state.live -= 1;
        state.retired.push(buffer);
    }
    // `live` shrank, so capacity exists again — a dropped render can
    // re-run into a fresh allocation. The notify inside doubles as the
    // retired-queue wake.
    shared.rearm_dropped_render();
    shared.notify();
}

/// Copy `h` rows of `w` BGRA pixels from a mapped source with row stride
/// `src_stride` bytes into a freshly allocated, tightly packed `w*h*4`
/// buffer (row 0 first). The one row-packing loop every backend's
/// `copy_pixels` shares — only the mapping bracket around it differs.
///
/// # Safety
/// `base` must point to at least `src_stride * h` readable bytes, with
/// `src_stride >= w * 4`.
unsafe fn pack_bgra_rows(base: *const u8, src_stride: usize, w: usize, h: usize) -> Vec<u8> {
    let mut out = vec![0u8; w * h * 4];
    for row in 0..h {
        // SAFETY: caller guarantees `base` covers `src_stride * h` bytes;
        // `out` is exactly `w * h * 4` and each write is `w * 4` at
        // `row * w * 4`, in bounds.
        unsafe {
            std::ptr::copy_nonoverlapping(
                base.add(row * src_stride),
                out.as_mut_ptr().add(row * w * 4),
                w * 4,
            );
        }
    }
    out
}

/// The wgpu backend [`ExportedFrame::into_wgpu_texture`] imports into on
/// this platform — Metal (macOS), Vulkan (Linux), or DX12 (Windows). The
/// import rejects a device on any other backend, so select this on your
/// [`wgpu::Instance`] (`InstanceDescriptor::backends`) to steer
/// `request_adapter` onto the matching one.
#[cfg(all(feature = "wgpu", target_os = "macos"))]
pub const WGPU_BACKEND: wgpu::Backends = wgpu::Backends::METAL;
/// See [`WGPU_BACKEND`].
#[cfg(all(feature = "wgpu", target_os = "linux"))]
pub const WGPU_BACKEND: wgpu::Backends = wgpu::Backends::VULKAN;
/// See [`WGPU_BACKEND`].
#[cfg(all(feature = "wgpu", target_os = "windows"))]
pub const WGPU_BACKEND: wgpu::Backends = wgpu::Backends::DX12;

/// The wgpu device features [`ExportedFrame::into_wgpu_texture`] requires
/// on this platform: `VULKAN_EXTERNAL_MEMORY_DMA_BUF` on Linux (the
/// dmabuf import opt-in), empty on macOS and Windows. Pass it in your
/// [`wgpu::DeviceDescriptor`]'s `required_features` — combine with your
/// own with `|`. Without it the import fails at runtime with
/// [`Error::WgpuImport`].
#[cfg(all(feature = "wgpu", target_os = "linux"))]
pub const REQUIRED_WGPU_FEATURES: wgpu::Features = wgpu::Features::VULKAN_EXTERNAL_MEMORY_DMA_BUF;
/// See [`REQUIRED_WGPU_FEATURES`].
#[cfg(all(feature = "wgpu", any(target_os = "macos", target_os = "windows")))]
pub const REQUIRED_WGPU_FEATURES: wgpu::Features = wgpu::Features::empty();

/// The `wgpu::TextureDescriptor` every `into_wgpu_texture` hands to
/// `create_texture_from_hal` — identical on all three backends
/// (`Bgra8Unorm`, `TEXTURE_BINDING | COPY_SRC`, 2D, one mip/sample). The
/// per-platform parts (initial `TextureUses`, how the hal texture is
/// built, return-vs-retire) stay in the platform modules.
#[cfg(feature = "wgpu")]
pub(super) fn exported_texture_desc(width: u32, height: u32) -> wgpu::TextureDescriptor<'static> {
    wgpu::TextureDescriptor {
        label: Some("mpv-exported-frame"),
        size: wgpu::Extent3d {
            width,
            height,
            depth_or_array_layers: 1,
        },
        mip_level_count: 1,
        sample_count: 1,
        dimension: wgpu::TextureDimension::D2,
        format: wgpu::TextureFormat::Bgra8Unorm,
        usage: wgpu::TextureUsages::TEXTURE_BINDING | wgpu::TextureUsages::COPY_SRC,
        view_formats: &[],
    }
}

impl std::fmt::Debug for ExportedFrame {
    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
        f.debug_struct("ExportedFrame")
            .field("width", &self.width())
            .field("height", &self.height())
            .finish_non_exhaustive()
    }
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn size_packing_round_trips() {
        assert_eq!(unpack_size(pack_size(3840, 2160)), (3840, 2160));
        assert_eq!(unpack_size(pack_size(0, 0)), (0, 0));
        assert_eq!(unpack_size(pack_size(u32::MAX, 1)), (u32::MAX, 1));
    }

    #[test]
    fn pool_size_clamps_to_two() {
        assert_eq!(ExportOptions::new(64, 64).pool_size(0).pool_size, 2);
        assert_eq!(ExportOptions::new(64, 64).pool_size(5).pool_size, 5);
    }
}