Skip to main content

edgefirst_image/gl/
threaded.rs

1// SPDX-FileCopyrightText: Copyright 2025 Au-Zone Technologies
2// SPDX-License-Identifier: Apache-2.0
3
4use edgefirst_decoder::{DetectBox, ProtoData, Segmentation};
5use std::panic::AssertUnwindSafe;
6use std::ptr::NonNull;
7use std::thread::JoinHandle;
8use tokio::sync::mpsc::{Sender, WeakSender};
9
10use super::processor::GLProcessorST;
11use super::shaders::check_gl_error;
12use super::{EglDisplayKind, Int8InterpolationMode, TransferBackend};
13use crate::{Crop, Error, Flip, ImageProcessorTrait, Rotation};
14use edgefirst_tensor::TensorDyn;
15
16#[allow(clippy::type_complexity)]
17enum GLProcessorMessage {
18    ImageConvert(
19        SendablePtr<TensorDyn>,
20        SendablePtr<TensorDyn>,
21        Rotation,
22        Flip,
23        Crop,
24        bool, // defer: skip the per-tile glFinish (batch); flush syncs once
25        tokio::sync::oneshot::Sender<Result<(), Error>>,
26    ),
27    /// Complete any deferred batch render with a single GPU sync.
28    Flush(tokio::sync::oneshot::Sender<Result<(), Error>>),
29    SetColors(
30        Vec<[u8; 4]>,
31        tokio::sync::oneshot::Sender<Result<(), Error>>,
32    ),
33    DrawDecodedMasks(
34        SendablePtr<TensorDyn>,
35        SendablePtr<DetectBox>,
36        SendablePtr<Segmentation>,
37        f32,                            // opacity
38        Option<SendablePtr<TensorDyn>>, // background
39        Option<[f32; 4]>,               // letterbox
40        crate::ColorMode,
41        tokio::sync::oneshot::Sender<Result<(), Error>>,
42    ),
43    DrawProtoMasks(
44        SendablePtr<TensorDyn>,
45        SendablePtr<DetectBox>,
46        SendablePtr<ProtoData>,
47        f32,                            // opacity
48        Option<SendablePtr<TensorDyn>>, // background
49        Option<[f32; 4]>,               // letterbox
50        crate::ColorMode,
51        tokio::sync::oneshot::Sender<Result<(), Error>>,
52    ),
53    SetInt8Interpolation(
54        Int8InterpolationMode,
55        tokio::sync::oneshot::Sender<Result<(), Error>>,
56    ),
57    /// Set the colorimetry/performance trade-off (`ColorimetryMode`).
58    SetColorimetryMode(
59        crate::ColorimetryMode,
60        tokio::sync::oneshot::Sender<Result<(), Error>>,
61    ),
62    /// Snapshot the EGLImage cache counters (steady-state import assertions).
63    EglCacheStats(tokio::sync::oneshot::Sender<Result<super::cache::GlCacheStats, Error>>),
64    PboCreate(
65        usize, // buffer size in bytes
66        tokio::sync::oneshot::Sender<Result<u32, Error>>,
67    ),
68    PboMap(
69        u32,   // buffer_id
70        usize, // size
71        tokio::sync::oneshot::Sender<Result<edgefirst_tensor::PboMapping, Error>>,
72    ),
73    PboUnmap(
74        u32, // buffer_id
75        tokio::sync::oneshot::Sender<Result<(), Error>>,
76    ),
77    PboDelete(u32), // fire-and-forget, no reply
78    CudaRegisterBuffer(u32, tokio::sync::oneshot::Sender<Option<usize>>),
79    CudaMap(usize, tokio::sync::oneshot::Sender<Option<(usize, usize)>>),
80    CudaUnmap(usize),      // fire-and-forget
81    CudaUnregister(usize), // fire-and-forget
82}
83
84/// Compute the flat element count for a PBO image buffer of the given format.
85///
86/// NV12 and NV16 are semiplanar with non-trivial element counts; all other
87/// formats use `width * height * channels`.
88///
89/// Returns `None` on `usize` overflow. A wrapped element count would size
90/// the PBO too small and corrupt memory on readback, so callers must treat
91/// `None` as an invalid-dimensions error (the same way they already reject
92/// a zero count).
93fn pbo_elem_count(
94    width: usize,
95    height: usize,
96    format: edgefirst_tensor::PixelFormat,
97) -> Option<usize> {
98    // Element count == product of the canonical image shape (the PBO holds u8,
99    // one byte per element). Delegating to `image_shape` keeps the combined
100    // semi-planar height in lockstep with every other consumer (and is exact
101    // for odd heights — `height*3/2` truncation under-sized odd-H NV12 by a
102    // row, and the old NV24 arm fell through to `channels` = far too small).
103    // `checked_mul` so a wrapped count can't silently undersize the PBO.
104    format
105        .image_shape(width, height)?
106        .iter()
107        .try_fold(1usize, |acc, &d| acc.checked_mul(d))
108}
109
110/// Compute the tensor shape for a PBO image of the given format — the canonical
111/// [`PixelFormat::image_shape`] (Planar `[C,H,W]`, SemiPlanar `[total_h, W]`
112/// with the odd-height-exact combined-plane height, Packed `[H,W,C]`).
113fn pbo_shape(width: usize, height: usize, format: edgefirst_tensor::PixelFormat) -> Vec<usize> {
114    format
115        .image_shape(width, height)
116        .unwrap_or_else(|| vec![height, width, format.channels()])
117}
118
119/// Best-effort registration of a freshly-created PBO with CUDA GL interop.
120///
121/// When libcudart is present, asks the GL worker (where the GL context is
122/// current) to call `cudaGraphicsGLRegisterBuffer` for `buffer_id`, then
123/// attaches a [`CudaHandle`] so `cuda_map()` can later yield a linear,
124/// 256-byte-aligned device pointer for TensorRT. On any failure (no CUDA,
125/// channel closed, registration rejected) the handle is left unset and the
126/// PBO is still usable as a normal CPU/GL buffer.
127fn register_pbo_cuda<T>(
128    tensor: &mut edgefirst_tensor::Tensor<T>,
129    buffer_id: u32,
130    size: usize,
131    sender: &Sender<GLProcessorMessage>,
132) where
133    T: num_traits::Num + Clone + std::fmt::Debug + Send + Sync,
134{
135    if !edgefirst_tensor::is_cuda_available() {
136        return;
137    }
138    let (tx, rx) = tokio::sync::oneshot::channel();
139    if sender
140        .blocking_send(GLProcessorMessage::CudaRegisterBuffer(buffer_id, tx))
141        .is_ok()
142    {
143        if let Ok(Some(resource)) = rx.blocking_recv() {
144            let ops = std::sync::Arc::new(GlCudaOps {
145                sender: sender.downgrade(),
146            });
147            tensor.set_cuda_handle(edgefirst_tensor::CudaHandle::new_gl(
148                resource as *mut std::ffi::c_void,
149                size,
150                ops,
151            ));
152        }
153    }
154}
155
156/// Implements PboOps by sending commands to the GL thread.
157///
158/// Uses a `WeakSender` so that PBO images don't keep the GL thread's channel
159/// alive. When the `GLProcessorThreaded` is dropped, its `Sender` is the last
160/// strong reference — dropping it closes the channel and lets the GL thread
161/// exit. PBO operations after that return `PboDisconnected`.
162struct GlPboOps {
163    sender: WeakSender<GLProcessorMessage>,
164}
165
166// SAFETY: GlPboOps sends all GL operations to the dedicated GL thread via a
167// channel. `map_buffer` returns a CPU-visible pointer from `glMapBufferRange`
168// that remains valid until `unmap_buffer` calls `glUnmapBuffer` on the GL thread.
169// `delete_buffer` sends a fire-and-forget deletion command to the GL thread.
170unsafe impl edgefirst_tensor::PboOps for GlPboOps {
171    fn map_buffer(
172        &self,
173        buffer_id: u32,
174        size: usize,
175    ) -> edgefirst_tensor::Result<edgefirst_tensor::PboMapping> {
176        let sender = self
177            .sender
178            .upgrade()
179            .ok_or(edgefirst_tensor::Error::PboDisconnected)?;
180        let (tx, rx) = tokio::sync::oneshot::channel();
181        sender
182            .blocking_send(GLProcessorMessage::PboMap(buffer_id, size, tx))
183            .map_err(|_| edgefirst_tensor::Error::PboDisconnected)?;
184        rx.blocking_recv()
185            .map_err(|_| edgefirst_tensor::Error::PboDisconnected)?
186            .map_err(|e| {
187                edgefirst_tensor::Error::NotImplemented(format!("GL PBO map failed: {e:?}"))
188            })
189    }
190
191    fn unmap_buffer(&self, buffer_id: u32) -> edgefirst_tensor::Result<()> {
192        let sender = self
193            .sender
194            .upgrade()
195            .ok_or(edgefirst_tensor::Error::PboDisconnected)?;
196        let (tx, rx) = tokio::sync::oneshot::channel();
197        sender
198            .blocking_send(GLProcessorMessage::PboUnmap(buffer_id, tx))
199            .map_err(|_| edgefirst_tensor::Error::PboDisconnected)?;
200        rx.blocking_recv()
201            .map_err(|_| edgefirst_tensor::Error::PboDisconnected)?
202            .map_err(|e| {
203                edgefirst_tensor::Error::NotImplemented(format!("GL PBO unmap failed: {e:?}"))
204            })
205    }
206
207    fn delete_buffer(&self, buffer_id: u32) {
208        if let Some(sender) = self.sender.upgrade() {
209            let _ = sender.blocking_send(GLProcessorMessage::PboDelete(buffer_id));
210        }
211    }
212}
213
214/// Routes CUDA GL-interop map/unmap/unregister to the GL thread.
215///
216/// Mirrors [`GlPboOps`]: holds a [`WeakSender`] so a registered PBO doesn't
217/// keep the GL thread's channel alive. The CUDA primitives
218/// (`cudaGraphicsMapResources` etc.) require the GL context to be current,
219/// so they must execute on the dedicated GL worker thread.
220struct GlCudaOps {
221    sender: WeakSender<GLProcessorMessage>,
222}
223
224impl edgefirst_tensor::CudaGlOps for GlCudaOps {
225    fn map(&self, resource: *mut std::ffi::c_void) -> Option<(*mut std::ffi::c_void, usize)> {
226        let sender = self.sender.upgrade()?;
227        let (tx, rx) = tokio::sync::oneshot::channel();
228        sender
229            .blocking_send(GLProcessorMessage::CudaMap(resource as usize, tx))
230            .ok()?;
231        rx.blocking_recv()
232            .ok()?
233            .map(|(p, l)| (p as *mut std::ffi::c_void, l))
234    }
235
236    fn unmap(&self, resource: *mut std::ffi::c_void) {
237        if let Some(sender) = self.sender.upgrade() {
238            let _ = sender.blocking_send(GLProcessorMessage::CudaUnmap(resource as usize));
239        }
240    }
241
242    fn unregister(&self, resource: *mut std::ffi::c_void) {
243        if let Some(sender) = self.sender.upgrade() {
244            let _ = sender.blocking_send(GLProcessorMessage::CudaUnregister(resource as usize));
245        }
246    }
247}
248
249/// OpenGL multi-threaded image converter. The actual conversion is done in a
250/// separate rendering thread, as OpenGL contexts are not thread-safe. This can
251/// be safely sent between threads. The `convert()` call sends the conversion
252/// request to the rendering thread and waits for the result.
253#[derive(Debug)]
254pub struct GLProcessorThreaded {
255    // This is only None when the converter is being dropped.
256    handle: Option<JoinHandle<()>>,
257
258    // This is only None when the converter is being dropped.
259    sender: Option<Sender<GLProcessorMessage>>,
260    /// Immutable capability surface (transfer backend, float render
261    /// support, serialization policy), captured once from the worker at
262    /// construction. See `PlatformCaps` in `platform/mod.rs`.
263    caps: super::platform::PlatformCaps,
264}
265
266unsafe impl Send for GLProcessorThreaded {}
267unsafe impl Sync for GLProcessorThreaded {}
268
269struct SendablePtr<T: Send> {
270    ptr: NonNull<T>,
271    len: usize,
272}
273
274unsafe impl<T> Send for SendablePtr<T> where T: Send {}
275
276/// Extract a human-readable message from a `catch_unwind` panic payload.
277fn panic_message(info: &(dyn std::any::Any + Send)) -> String {
278    if let Some(s) = info.downcast_ref::<&str>() {
279        s.to_string()
280    } else if let Some(s) = info.downcast_ref::<String>() {
281        s.clone()
282    } else {
283        "unknown panic".to_string()
284    }
285}
286
287impl GLProcessorThreaded {
288    /// Creates a new OpenGL multi-threaded image converter.
289    pub fn new(kind: Option<EglDisplayKind>) -> Result<Self, Error> {
290        let (send, mut recv) = tokio::sync::mpsc::channel::<GLProcessorMessage>(1);
291
292        let (create_ctx_send, create_ctx_recv) = tokio::sync::oneshot::channel();
293
294        let func = move || {
295            // Creation runs under the global lifecycle lock on Linux
296            // (bring-up races on some embedded drivers); ANGLE serializes
297            // display-level entry points internally, so macOS needs none
298            // (validated by the A0 lifecycle-churn spike leg).
299            #[cfg(target_os = "linux")]
300            let init_result = {
301                let _guard = super::context::GL_MUTEX
302                    .lock()
303                    .unwrap_or_else(|e| e.into_inner());
304                GLProcessorST::new(kind)
305            };
306            #[cfg(target_os = "macos")]
307            let init_result = GLProcessorST::new(kind);
308            let mut gl_converter = match init_result {
309                Ok(gl) => gl,
310                Err(e) => {
311                    let _ = create_ctx_send.send(Err(e));
312                    return;
313                }
314            };
315            // Capability surface captured ONCE before the message loop —
316            // immutable for the worker's life, never re-probed per message.
317            let caps = gl_converter.platform_caps();
318            let _ = create_ctx_send.send(Ok(caps));
319            let mut poisoned = false;
320            // Per-message serialization policy: Full where the platform
321            // demands it (`caps.serialize_gl` — Vivante/galcore is not
322            // thread-safe for concurrent GL across contexts), LifecycleOnly
323            // everywhere else — multiple processors then run GL work in
324            // parallel, each on its own thread + context. See the GL_MUTEX
325            // doc comment in context.rs. EDGEFIRST_GL_SERIALIZE overrides
326            // per-driver defaults (full = escape hatch for unknown-bad
327            // drivers; lifecycle = force-parallel).
328            let serialize_per_msg = match std::env::var("EDGEFIRST_GL_SERIALIZE").as_deref() {
329                Ok("full") => true,
330                Ok("lifecycle") => false,
331                _ => caps.serialize_gl,
332            };
333            log::debug!(
334                "GL serialization policy: {}",
335                if serialize_per_msg {
336                    "Full (per-message global lock)"
337                } else {
338                    "LifecycleOnly (parallel across processors)"
339                }
340            );
341            while let Some(msg) = recv.blocking_recv() {
342                // Full policy: one processor's message at a time, process-wide.
343                // Linux locks GL_MUTEX (messages must also exclude the locked
344                // lifecycle operations — on Vivante a convert racing a context
345                // bring-up is part of the driver bug class). macOS has no
346                // lifecycle lock (ANGLE serializes display entry points
347                // internally), so message-vs-message exclusion suffices —
348                // needed on virtualized GPUs, where concurrent GL across
349                // contexts mis-renders (paravirtual Metal, macOS CI runners).
350                #[cfg(target_os = "linux")]
351                let _guard = serialize_per_msg.then(|| {
352                    super::context::GL_MUTEX
353                        .lock()
354                        .unwrap_or_else(|e| e.into_inner())
355                });
356                #[cfg(target_os = "macos")]
357                let _guard = serialize_per_msg.then(|| {
358                    static MSG_MUTEX: std::sync::Mutex<()> = std::sync::Mutex::new(());
359                    MSG_MUTEX.lock().unwrap_or_else(|e| e.into_inner())
360                });
361
362                // After a panic, the GL context is in an undefined state. Reject
363                // all subsequent messages with an error rather than risking wrong
364                // output or a GPU hang from corrupted GL state. This follows the
365                // same pattern as std::sync::Mutex poisoning.
366                if poisoned {
367                    let poison_err = crate::Error::Internal(
368                        "GL context is poisoned after a prior panic".to_string(),
369                    );
370                    match msg {
371                        GLProcessorMessage::ImageConvert(.., resp) => {
372                            let _ = resp.send(Err(poison_err));
373                        }
374                        GLProcessorMessage::Flush(resp) => {
375                            let _ = resp.send(Err(poison_err));
376                        }
377                        GLProcessorMessage::DrawDecodedMasks(.., resp) => {
378                            let _ = resp.send(Err(poison_err));
379                        }
380                        GLProcessorMessage::DrawProtoMasks(.., resp) => {
381                            let _ = resp.send(Err(poison_err));
382                        }
383                        GLProcessorMessage::SetColors(_, resp) => {
384                            let _ = resp.send(Err(poison_err));
385                        }
386                        GLProcessorMessage::SetInt8Interpolation(_, resp) => {
387                            let _ = resp.send(Err(poison_err));
388                        }
389                        GLProcessorMessage::SetColorimetryMode(_, resp) => {
390                            let _ = resp.send(Err(poison_err));
391                        }
392                        GLProcessorMessage::EglCacheStats(resp) => {
393                            let _ = resp.send(Err(poison_err));
394                        }
395                        GLProcessorMessage::PboCreate(_, resp) => {
396                            let _ = resp.send(Err(poison_err));
397                        }
398                        GLProcessorMessage::PboMap(_, _, resp) => {
399                            let _ = resp.send(Err(poison_err));
400                        }
401                        GLProcessorMessage::PboUnmap(_, resp) => {
402                            let _ = resp.send(Err(poison_err));
403                        }
404                        GLProcessorMessage::PboDelete(_) => {}
405                        GLProcessorMessage::CudaRegisterBuffer(_, resp) => {
406                            let _ = resp.send(None);
407                        }
408                        GLProcessorMessage::CudaMap(_, resp) => {
409                            let _ = resp.send(None);
410                        }
411                        // GL context is poisoned/dead — the GL buffer is unusable, so unmapping/
412                        // unregistering the CUDA interop is moot (the resource is reclaimed at
413                        // process exit). Same accepted "GL is gone" no-op as PboDelete above.
414                        GLProcessorMessage::CudaUnmap(_) => {}
415                        GLProcessorMessage::CudaUnregister(_) => {}
416                    }
417                    continue;
418                }
419
420                match msg {
421                    GLProcessorMessage::ImageConvert(
422                        src,
423                        mut dst,
424                        rotation,
425                        flip,
426                        crop,
427                        defer,
428                        resp,
429                    ) => {
430                        // SAFETY: This is safe because the convert() function waits for the resp to
431                        // be sent before dropping the borrow for src and dst
432                        let result = std::panic::catch_unwind(AssertUnwindSafe(|| {
433                            let src = unsafe { src.ptr.as_ref() };
434                            let dst = unsafe { dst.ptr.as_mut() };
435                            // A deferred convert skips the per-tile glFinish and
436                            // leaves a single sync owed at Flush; an eager convert
437                            // finishes as usual.
438                            if defer {
439                                gl_converter.convert_deferred(src, dst, rotation, flip, crop)
440                            } else {
441                                gl_converter.convert(src, dst, rotation, flip, crop)
442                            }
443                        }));
444                        let _ = resp.send(match result {
445                            Ok(res) => res,
446                            Err(e) => {
447                                poisoned = true;
448                                Err(crate::Error::Internal(format!(
449                                    "GL thread panicked during ImageConvert: {}",
450                                    panic_message(e.as_ref()),
451                                )))
452                            }
453                        });
454                    }
455                    GLProcessorMessage::Flush(resp) => {
456                        let result =
457                            std::panic::catch_unwind(AssertUnwindSafe(|| gl_converter.flush()));
458                        let _ = resp.send(match result {
459                            Ok(res) => res,
460                            Err(e) => {
461                                poisoned = true;
462                                Err(crate::Error::Internal(format!(
463                                    "GL thread panicked during Flush: {}",
464                                    panic_message(e.as_ref()),
465                                )))
466                            }
467                        });
468                    }
469                    GLProcessorMessage::DrawDecodedMasks(
470                        mut dst,
471                        det,
472                        seg,
473                        opacity,
474                        bg,
475                        letterbox,
476                        color_mode,
477                        resp,
478                    ) => {
479                        // SAFETY: This is safe because the draw_decoded_masks() function waits for the
480                        // resp to be sent before dropping the borrow for dst, detect,
481                        // segmentation, and background
482                        let result = std::panic::catch_unwind(AssertUnwindSafe(|| {
483                            let dst = unsafe { dst.ptr.as_mut() };
484                            let det =
485                                unsafe { std::slice::from_raw_parts(det.ptr.as_ptr(), det.len) };
486                            let seg =
487                                unsafe { std::slice::from_raw_parts(seg.ptr.as_ptr(), seg.len) };
488                            let bg_ref = bg.map(|p| unsafe { &*p.ptr.as_ptr() });
489                            gl_converter.draw_decoded_masks(
490                                dst,
491                                det,
492                                seg,
493                                crate::MaskOverlay {
494                                    background: bg_ref,
495                                    opacity,
496                                    letterbox,
497                                    color_mode,
498                                },
499                            )
500                        }));
501                        let _ = resp.send(match result {
502                            Ok(res) => res,
503                            Err(e) => {
504                                poisoned = true;
505                                Err(crate::Error::Internal(format!(
506                                    "GL thread panicked during DrawDecodedMasks: {}",
507                                    panic_message(e.as_ref()),
508                                )))
509                            }
510                        });
511                    }
512                    GLProcessorMessage::DrawProtoMasks(
513                        mut dst,
514                        det,
515                        proto_data,
516                        opacity,
517                        bg,
518                        letterbox,
519                        color_mode,
520                        resp,
521                    ) => {
522                        // SAFETY: Same safety invariant as DrawDecodedMasks — caller
523                        // blocks on resp before dropping borrows.
524                        let result = std::panic::catch_unwind(AssertUnwindSafe(|| {
525                            let dst = unsafe { dst.ptr.as_mut() };
526                            let det =
527                                unsafe { std::slice::from_raw_parts(det.ptr.as_ptr(), det.len) };
528                            let bg_ref = bg.map(|p| unsafe { &*p.ptr.as_ptr() });
529                            let proto_data = unsafe { proto_data.ptr.as_ref() };
530                            gl_converter.draw_proto_masks(
531                                dst,
532                                det,
533                                proto_data,
534                                crate::MaskOverlay {
535                                    background: bg_ref,
536                                    opacity,
537                                    letterbox,
538                                    color_mode,
539                                },
540                            )
541                        }));
542                        let _ = resp.send(match result {
543                            Ok(res) => res,
544                            Err(e) => {
545                                poisoned = true;
546                                Err(crate::Error::Internal(format!(
547                                    "GL thread panicked during DrawProtoMasks: {}",
548                                    panic_message(e.as_ref()),
549                                )))
550                            }
551                        });
552                    }
553                    GLProcessorMessage::SetColors(colors, resp) => {
554                        let result = std::panic::catch_unwind(AssertUnwindSafe(|| {
555                            gl_converter.set_class_colors(&colors)
556                        }));
557                        let _ = resp.send(match result {
558                            Ok(res) => res,
559                            Err(e) => {
560                                poisoned = true;
561                                Err(crate::Error::Internal(format!(
562                                    "GL thread panicked during SetColors: {}",
563                                    panic_message(e.as_ref()),
564                                )))
565                            }
566                        });
567                    }
568                    GLProcessorMessage::SetColorimetryMode(mode, resp) => {
569                        let result = std::panic::catch_unwind(AssertUnwindSafe(|| {
570                            gl_converter.set_colorimetry_mode(mode);
571                            Ok(())
572                        }));
573                        let _ = resp.send(match result {
574                            Ok(res) => res,
575                            Err(e) => {
576                                poisoned = true;
577                                Err(crate::Error::Internal(format!(
578                                    "GL thread panicked during SetColorimetryMode: {}",
579                                    panic_message(e.as_ref()),
580                                )))
581                            }
582                        });
583                    }
584                    GLProcessorMessage::SetInt8Interpolation(mode, resp) => {
585                        let result = std::panic::catch_unwind(AssertUnwindSafe(|| {
586                            gl_converter.set_int8_interpolation_mode(mode);
587                            Ok(())
588                        }));
589                        let _ = resp.send(match result {
590                            Ok(res) => res,
591                            Err(e) => {
592                                poisoned = true;
593                                Err(crate::Error::Internal(format!(
594                                    "GL thread panicked during SetInt8Interpolation: {}",
595                                    panic_message(e.as_ref()),
596                                )))
597                            }
598                        });
599                    }
600                    GLProcessorMessage::EglCacheStats(resp) => {
601                        let result = std::panic::catch_unwind(AssertUnwindSafe(|| {
602                            Ok(gl_converter.egl_cache_stats())
603                        }));
604                        let _ = resp.send(match result {
605                            Ok(res) => res,
606                            Err(e) => {
607                                poisoned = true;
608                                Err(crate::Error::Internal(format!(
609                                    "GL thread panicked during EglCacheStats: {}",
610                                    panic_message(e.as_ref()),
611                                )))
612                            }
613                        });
614                    }
615                    GLProcessorMessage::PboCreate(size, resp) => {
616                        let result = std::panic::catch_unwind(AssertUnwindSafe(|| unsafe {
617                            let mut id: u32 = 0;
618                            edgefirst_gl::gl::GenBuffers(1, &mut id);
619                            edgefirst_gl::gl::BindBuffer(edgefirst_gl::gl::PIXEL_PACK_BUFFER, id);
620                            edgefirst_gl::gl::BufferData(
621                                edgefirst_gl::gl::PIXEL_PACK_BUFFER,
622                                size as isize,
623                                std::ptr::null(),
624                                edgefirst_gl::gl::STREAM_COPY,
625                            );
626                            edgefirst_gl::gl::BindBuffer(edgefirst_gl::gl::PIXEL_PACK_BUFFER, 0);
627                            match check_gl_error("PboCreate", 0) {
628                                Ok(()) => Ok(id),
629                                Err(e) => {
630                                    edgefirst_gl::gl::DeleteBuffers(1, &id);
631                                    Err(e)
632                                }
633                            }
634                        }));
635                        let _ = resp.send(match result {
636                            Ok(res) => res,
637                            Err(e) => {
638                                poisoned = true;
639                                Err(crate::Error::Internal(format!(
640                                    "GL thread panicked during PboCreate: {}",
641                                    panic_message(e.as_ref()),
642                                )))
643                            }
644                        });
645                    }
646                    GLProcessorMessage::PboMap(buffer_id, size, resp) => {
647                        let result = std::panic::catch_unwind(AssertUnwindSafe(|| unsafe {
648                            edgefirst_gl::gl::BindBuffer(
649                                edgefirst_gl::gl::PIXEL_PACK_BUFFER,
650                                buffer_id,
651                            );
652                            let ptr = edgefirst_gl::gl::MapBufferRange(
653                                edgefirst_gl::gl::PIXEL_PACK_BUFFER,
654                                0,
655                                size as isize,
656                                edgefirst_gl::gl::MAP_READ_BIT | edgefirst_gl::gl::MAP_WRITE_BIT,
657                            );
658                            edgefirst_gl::gl::BindBuffer(edgefirst_gl::gl::PIXEL_PACK_BUFFER, 0);
659                            if ptr.is_null() {
660                                Err(crate::Error::OpenGl(
661                                    "glMapBufferRange returned null".to_string(),
662                                ))
663                            } else {
664                                Ok(edgefirst_tensor::PboMapping {
665                                    ptr: ptr as *mut u8,
666                                    size,
667                                })
668                            }
669                        }));
670                        let _ = resp.send(match result {
671                            Ok(res) => res,
672                            Err(e) => {
673                                poisoned = true;
674                                Err(crate::Error::Internal(format!(
675                                    "GL thread panicked during PboMap: {}",
676                                    panic_message(e.as_ref()),
677                                )))
678                            }
679                        });
680                    }
681                    GLProcessorMessage::PboUnmap(buffer_id, resp) => {
682                        let result = std::panic::catch_unwind(AssertUnwindSafe(|| unsafe {
683                            edgefirst_gl::gl::BindBuffer(
684                                edgefirst_gl::gl::PIXEL_PACK_BUFFER,
685                                buffer_id,
686                            );
687                            let ok =
688                                edgefirst_gl::gl::UnmapBuffer(edgefirst_gl::gl::PIXEL_PACK_BUFFER);
689                            edgefirst_gl::gl::BindBuffer(edgefirst_gl::gl::PIXEL_PACK_BUFFER, 0);
690                            if ok == edgefirst_gl::gl::FALSE {
691                                Err(Error::OpenGl(
692                                    "PBO data was corrupted during mapping".into(),
693                                ))
694                            } else {
695                                check_gl_error("PboUnmap", 0)
696                            }
697                        }));
698                        let _ = resp.send(match result {
699                            Ok(res) => res,
700                            Err(e) => {
701                                poisoned = true;
702                                Err(crate::Error::Internal(format!(
703                                    "GL thread panicked during PboUnmap: {}",
704                                    panic_message(e.as_ref()),
705                                )))
706                            }
707                        });
708                    }
709                    GLProcessorMessage::PboDelete(buffer_id) => {
710                        if let Err(e) = std::panic::catch_unwind(AssertUnwindSafe(|| unsafe {
711                            edgefirst_gl::gl::DeleteBuffers(1, &buffer_id);
712                        })) {
713                            poisoned = true;
714                            log::error!(
715                                "GL thread panicked during PboDelete: {}",
716                                panic_message(e.as_ref()),
717                            );
718                        }
719                    }
720                    GLProcessorMessage::CudaRegisterBuffer(buffer_id, resp) => {
721                        // CUDA GL-interop must run on the GL-context thread.
722                        let _ = resp.send(edgefirst_tensor::gl_register_buffer(buffer_id));
723                    }
724                    GLProcessorMessage::CudaMap(resource, resp) => {
725                        // Auto-flush: if a batched `convert_deferred` is still
726                        // owed its single GPU sync, complete it before CUDA maps
727                        // the buffer — otherwise the device could read a render
728                        // still in flight. This makes `cuda_map()` correct on a
729                        // batched output with no API change (the caller pays the
730                        // sync it would have owed anyway).
731                        gl_converter.flush_pending();
732                        let _ = resp.send(edgefirst_tensor::gl_map_resource(resource));
733                    }
734                    GLProcessorMessage::CudaUnmap(resource) => {
735                        edgefirst_tensor::gl_unmap_resource(resource);
736                    }
737                    GLProcessorMessage::CudaUnregister(resource) => {
738                        edgefirst_tensor::gl_unregister_resource(resource);
739                    }
740                }
741                // Per-pass platform texture attachments (macOS pbuffer
742                // binds) are released once the message's GPU work has
743                // synced; deferred batches keep theirs until Flush.
744                gl_converter.end_gpu_pass_if_synced();
745            }
746            // Explicitly drop under the mutex so EGL teardown is serialized
747            // (Linux; ANGLE teardown is display-internal on macOS).
748            #[cfg(target_os = "linux")]
749            let _guard = super::context::GL_MUTEX
750                .lock()
751                .unwrap_or_else(|e| e.into_inner());
752            drop(gl_converter);
753        };
754
755        // let handle = tokio::task::spawn(func());
756        let handle = std::thread::spawn(func);
757
758        let caps = match create_ctx_recv.blocking_recv() {
759            Ok(Err(e)) => return Err(e),
760            Err(_) => {
761                return Err(Error::Internal(
762                    "GL converter error messaging closed without update".to_string(),
763                ));
764            }
765            Ok(Ok(caps)) => caps,
766        };
767
768        Ok(Self {
769            handle: Some(handle),
770            sender: Some(send),
771            caps,
772        })
773    }
774}
775
776impl ImageProcessorTrait for GLProcessorThreaded {
777    fn convert(
778        &mut self,
779        src: &TensorDyn,
780        dst: &mut TensorDyn,
781        rotation: crate::Rotation,
782        flip: Flip,
783        crop: Crop,
784    ) -> crate::Result<()> {
785        let (err_send, err_recv) = tokio::sync::oneshot::channel();
786        self.sender
787            .as_ref()
788            .ok_or_else(|| Error::Internal("GL processor is shutting down".to_string()))?
789            .blocking_send(GLProcessorMessage::ImageConvert(
790                SendablePtr {
791                    ptr: NonNull::from(src),
792                    len: 1,
793                },
794                SendablePtr {
795                    ptr: NonNull::from(dst),
796                    len: 1,
797                },
798                rotation,
799                flip,
800                crop,
801                false, // eager: finish per convert
802                err_send,
803            ))
804            .map_err(|_| Error::Internal("GL converter thread exited".to_string()))?;
805        err_recv.blocking_recv().map_err(|_| {
806            Error::Internal("GL converter error messaging closed without update".to_string())
807        })?
808    }
809
810    fn convert_deferred(
811        &mut self,
812        src: &TensorDyn,
813        dst: &mut TensorDyn,
814        rotation: crate::Rotation,
815        flip: Flip,
816        crop: Crop,
817    ) -> crate::Result<()> {
818        let (err_send, err_recv) = tokio::sync::oneshot::channel();
819        self.sender
820            .as_ref()
821            .ok_or_else(|| Error::Internal("GL processor is shutting down".to_string()))?
822            .blocking_send(GLProcessorMessage::ImageConvert(
823                SendablePtr {
824                    ptr: NonNull::from(src),
825                    len: 1,
826                },
827                SendablePtr {
828                    ptr: NonNull::from(dst),
829                    len: 1,
830                },
831                rotation,
832                flip,
833                crop,
834                true, // defer: skip glFinish; flush() syncs the batch once
835                err_send,
836            ))
837            .map_err(|_| Error::Internal("GL converter thread exited".to_string()))?;
838        err_recv.blocking_recv().map_err(|_| {
839            Error::Internal("GL converter error messaging closed without update".to_string())
840        })?
841    }
842
843    fn flush(&mut self) -> crate::Result<()> {
844        let (err_send, err_recv) = tokio::sync::oneshot::channel();
845        self.sender
846            .as_ref()
847            .ok_or_else(|| Error::Internal("GL processor is shutting down".to_string()))?
848            .blocking_send(GLProcessorMessage::Flush(err_send))
849            .map_err(|_| Error::Internal("GL converter thread exited".to_string()))?;
850        err_recv.blocking_recv().map_err(|_| {
851            Error::Internal("GL converter error messaging closed without update".to_string())
852        })?
853    }
854
855    fn draw_decoded_masks(
856        &mut self,
857        dst: &mut TensorDyn,
858        detect: &[crate::DetectBox],
859        segmentation: &[crate::Segmentation],
860        overlay: crate::MaskOverlay<'_>,
861    ) -> crate::Result<()> {
862        let (err_send, err_recv) = tokio::sync::oneshot::channel();
863        self.sender
864            .as_ref()
865            .ok_or_else(|| Error::Internal("GL processor is shutting down".to_string()))?
866            .blocking_send(GLProcessorMessage::DrawDecodedMasks(
867                SendablePtr {
868                    ptr: NonNull::from(dst),
869                    len: 1,
870                },
871                SendablePtr {
872                    ptr: NonNull::new(detect.as_ptr() as *mut DetectBox).unwrap(),
873                    len: detect.len(),
874                },
875                SendablePtr {
876                    ptr: NonNull::new(segmentation.as_ptr() as *mut Segmentation).unwrap(),
877                    len: segmentation.len(),
878                },
879                overlay.opacity,
880                overlay.background.map(|bg| SendablePtr {
881                    ptr: NonNull::from(bg).cast::<TensorDyn>(),
882                    len: 1,
883                }),
884                overlay.letterbox,
885                overlay.color_mode,
886                err_send,
887            ))
888            .map_err(|_| Error::Internal("GL converter thread exited".to_string()))?;
889        err_recv.blocking_recv().map_err(|_| {
890            Error::Internal("GL converter error messaging closed without update".to_string())
891        })?
892    }
893
894    fn draw_proto_masks(
895        &mut self,
896        dst: &mut TensorDyn,
897        detect: &[DetectBox],
898        proto_data: &ProtoData,
899        overlay: crate::MaskOverlay<'_>,
900    ) -> crate::Result<()> {
901        let (err_send, err_recv) = tokio::sync::oneshot::channel();
902        self.sender
903            .as_ref()
904            .ok_or_else(|| Error::Internal("GL processor is shutting down".to_string()))?
905            .blocking_send(GLProcessorMessage::DrawProtoMasks(
906                SendablePtr {
907                    ptr: NonNull::from(dst),
908                    len: 1,
909                },
910                SendablePtr {
911                    ptr: NonNull::new(detect.as_ptr() as *mut DetectBox).unwrap(),
912                    len: detect.len(),
913                },
914                SendablePtr {
915                    ptr: NonNull::from(proto_data).cast::<ProtoData>(),
916                    len: 1,
917                },
918                overlay.opacity,
919                overlay.background.map(|bg| SendablePtr {
920                    ptr: NonNull::from(bg).cast::<TensorDyn>(),
921                    len: 1,
922                }),
923                overlay.letterbox,
924                overlay.color_mode,
925                err_send,
926            ))
927            .map_err(|_| Error::Internal("GL converter thread exited".to_string()))?;
928        err_recv.blocking_recv().map_err(|_| {
929            Error::Internal("GL converter error messaging closed without update".to_string())
930        })?
931    }
932
933    fn set_class_colors(&mut self, colors: &[[u8; 4]]) -> Result<(), crate::Error> {
934        let (err_send, err_recv) = tokio::sync::oneshot::channel();
935        self.sender
936            .as_ref()
937            .ok_or_else(|| Error::Internal("GL processor is shutting down".to_string()))?
938            .blocking_send(GLProcessorMessage::SetColors(colors.to_vec(), err_send))
939            .map_err(|_| Error::Internal("GL converter thread exited".to_string()))?;
940        err_recv.blocking_recv().map_err(|_| {
941            Error::Internal("GL converter error messaging closed without update".to_string())
942        })?
943    }
944}
945
946impl GLProcessorThreaded {
947    /// Sets the colorimetry/performance trade-off (see
948    /// [`crate::ColorimetryMode`]). The `EDGEFIRST_COLORIMETRY` environment
949    /// variable takes precedence — when set, this call logs and keeps the
950    /// env-selected mode.
951    pub fn set_colorimetry_mode(&mut self, mode: crate::ColorimetryMode) -> Result<(), Error> {
952        let (err_send, err_recv) = tokio::sync::oneshot::channel();
953        self.sender
954            .as_ref()
955            .ok_or_else(|| Error::Internal("GL processor is shutting down".to_string()))?
956            .blocking_send(GLProcessorMessage::SetColorimetryMode(mode, err_send))
957            .map_err(|_| Error::Internal("GL converter thread exited".to_string()))?;
958        err_recv.blocking_recv().map_err(|_| {
959            Error::Internal("GL converter error messaging closed without update".to_string())
960        })?
961    }
962
963    /// Sets the interpolation mode for int8 proto textures.
964    pub fn set_int8_interpolation_mode(
965        &mut self,
966        mode: Int8InterpolationMode,
967    ) -> Result<(), crate::Error> {
968        let (err_send, err_recv) = tokio::sync::oneshot::channel();
969        self.sender
970            .as_ref()
971            .ok_or_else(|| Error::Internal("GL processor is shutting down".to_string()))?
972            .blocking_send(GLProcessorMessage::SetInt8Interpolation(mode, err_send))
973            .map_err(|_| Error::Internal("GL converter thread exited".to_string()))?;
974        err_recv.blocking_recv().map_err(|_| {
975            Error::Internal("GL converter error messaging closed without update".to_string())
976        })?
977    }
978
979    /// Snapshot the EGLImage cache counters (src, dst, NV R8) from the GL
980    /// thread. Steady-state tests capture this after warmup and after an
981    /// N-frame loop over a fixed buffer pool and assert
982    /// [`total_misses`](super::cache::GlCacheStats::total_misses) stays flat —
983    /// any increase means a convert re-imported a buffer it should have found
984    /// cached (the cache-behavior equality gate for GL refactors).
985    pub fn egl_cache_stats(&self) -> Result<super::cache::GlCacheStats, Error> {
986        let (send, recv) = tokio::sync::oneshot::channel();
987        self.sender
988            .as_ref()
989            .ok_or_else(|| Error::Internal("GL processor is shutting down".to_string()))?
990            .blocking_send(GLProcessorMessage::EglCacheStats(send))
991            .map_err(|_| Error::Internal("GL converter thread exited".to_string()))?;
992        recv.blocking_recv().map_err(|_| {
993            Error::Internal("GL converter error messaging closed without update".to_string())
994        })?
995    }
996
997    /// Create a PBO-backed [`Tensor<u8>`] image on the GL thread.
998    pub fn create_pbo_image(
999        &self,
1000        width: usize,
1001        height: usize,
1002        format: edgefirst_tensor::PixelFormat,
1003    ) -> Result<edgefirst_tensor::Tensor<u8>, Error> {
1004        let sender = self
1005            .sender
1006            .as_ref()
1007            .ok_or(Error::OpenGl("GL processor is shutting down".to_string()))?;
1008
1009        let size = pbo_elem_count(width, height, format)
1010            .filter(|&s| s != 0)
1011            .ok_or_else(|| Error::OpenGl("Invalid image dimensions".to_string()))?;
1012
1013        // Allocate PBO on the GL thread
1014        let (tx, rx) = tokio::sync::oneshot::channel();
1015        sender
1016            .blocking_send(GLProcessorMessage::PboCreate(size, tx))
1017            .map_err(|_| Error::OpenGl("GL thread channel closed".to_string()))?;
1018        let buffer_id = rx
1019            .blocking_recv()
1020            .map_err(|_| Error::OpenGl("GL thread did not respond".to_string()))??;
1021
1022        let ops: std::sync::Arc<dyn edgefirst_tensor::PboOps> = std::sync::Arc::new(GlPboOps {
1023            sender: sender.downgrade(),
1024        });
1025
1026        let shape = pbo_shape(width, height, format);
1027
1028        let pbo_tensor =
1029            edgefirst_tensor::PboTensor::<u8>::from_pbo(buffer_id, size, &shape, None, ops)
1030                .map_err(|e| Error::OpenGl(format!("PBO tensor creation failed: {e:?}")))?;
1031        let mut tensor = edgefirst_tensor::Tensor::from_pbo(pbo_tensor);
1032        tensor
1033            .set_format(format)
1034            .map_err(|e| Error::OpenGl(format!("Failed to set format on PBO tensor: {e:?}")))?;
1035        // Register the PBO with CUDA (best-effort; no-op without libcudart) so
1036        // `cuda_map()` can yield a device pointer — matching the float PBO path
1037        // in `create_pbo_image_dtype`. Without this, u8 PBOs were never
1038        // CUDA-interop-capable, so the codec's nvJPEG backend (and any CUDA
1039        // consumer) could not use a `create_image`-allocated PBO destination.
1040        // The i8 transmute by the caller preserves the attached handle.
1041        register_pbo_cuda(&mut tensor, buffer_id, size, sender);
1042        Ok(tensor)
1043    }
1044
1045    /// Create a PBO-backed [`TensorDyn`] image on the GL thread with the given dtype.
1046    ///
1047    /// Sizes the underlying GL buffer by `elems * dtype.size()` and wraps it in
1048    /// the appropriately-typed [`PboTensor`]. Supports `DType::U8`, `DType::F16`,
1049    /// and `DType::F32`; returns an error for other dtypes.
1050    pub(crate) fn create_pbo_image_dtype(
1051        &self,
1052        width: usize,
1053        height: usize,
1054        format: edgefirst_tensor::PixelFormat,
1055        dtype: edgefirst_tensor::DType,
1056    ) -> Result<TensorDyn, Error> {
1057        let sender = self
1058            .sender
1059            .as_ref()
1060            .ok_or(Error::OpenGl("GL processor is shutting down".to_string()))?;
1061
1062        let elems = pbo_elem_count(width, height, format)
1063            .filter(|&e| e != 0)
1064            .ok_or_else(|| Error::OpenGl("Invalid image dimensions".to_string()))?;
1065
1066        let size = elems
1067            .checked_mul(dtype.size())
1068            .ok_or_else(|| Error::OpenGl("PBO size overflow".to_string()))?;
1069
1070        // Allocate PBO on the GL thread
1071        let (tx, rx) = tokio::sync::oneshot::channel();
1072        sender
1073            .blocking_send(GLProcessorMessage::PboCreate(size, tx))
1074            .map_err(|_| Error::OpenGl("GL thread channel closed".to_string()))?;
1075        let buffer_id = rx
1076            .blocking_recv()
1077            .map_err(|_| Error::OpenGl("GL thread did not respond".to_string()))??;
1078
1079        let ops: std::sync::Arc<dyn edgefirst_tensor::PboOps> = std::sync::Arc::new(GlPboOps {
1080            sender: sender.downgrade(),
1081        });
1082
1083        let shape = pbo_shape(width, height, format);
1084
1085        let map_err = |e: edgefirst_tensor::Error| {
1086            Error::OpenGl(format!("PBO tensor creation failed: {e:?}"))
1087        };
1088        let set_err = |e: edgefirst_tensor::Error| {
1089            Error::OpenGl(format!("Failed to set format on PBO tensor: {e:?}"))
1090        };
1091
1092        match dtype {
1093            edgefirst_tensor::DType::U8 => {
1094                let pbo =
1095                    edgefirst_tensor::PboTensor::<u8>::from_pbo(buffer_id, size, &shape, None, ops)
1096                        .map_err(map_err)?;
1097                let mut t = edgefirst_tensor::Tensor::from_pbo(pbo);
1098                t.set_format(format).map_err(set_err)?;
1099                register_pbo_cuda(&mut t, buffer_id, size, sender);
1100                Ok(TensorDyn::from(t))
1101            }
1102            edgefirst_tensor::DType::F16 => {
1103                let pbo = edgefirst_tensor::PboTensor::<edgefirst_tensor::f16>::from_pbo(
1104                    buffer_id, size, &shape, None, ops,
1105                )
1106                .map_err(map_err)?;
1107                let mut t = edgefirst_tensor::Tensor::from_pbo(pbo);
1108                t.set_format(format).map_err(set_err)?;
1109                register_pbo_cuda(&mut t, buffer_id, size, sender);
1110                Ok(TensorDyn::from(t))
1111            }
1112            edgefirst_tensor::DType::F32 => {
1113                let pbo = edgefirst_tensor::PboTensor::<f32>::from_pbo(
1114                    buffer_id, size, &shape, None, ops,
1115                )
1116                .map_err(map_err)?;
1117                let mut t = edgefirst_tensor::Tensor::from_pbo(pbo);
1118                t.set_format(format).map_err(set_err)?;
1119                register_pbo_cuda(&mut t, buffer_id, size, sender);
1120                Ok(TensorDyn::from(t))
1121            }
1122            other => Err(Error::OpenGl(format!("unsupported PBO dtype {other:?}"))),
1123        }
1124    }
1125
1126    /// Returns the active transfer backend.
1127    pub(crate) fn transfer_backend(&self) -> TransferBackend {
1128        self.caps.transfer_backend
1129    }
1130
1131    /// Report which float dtypes the GPU can render to.
1132    ///
1133    /// Values are probed once at construction time and adjusted for
1134    /// Vivante GC7000UL, whose float readback latency (170-320 ms) makes
1135    /// GL float destinations impractical; `ImageProcessor::convert()` falls
1136    /// back to CPU float output (normalized to `[0, 1]`) for these targets.
1137    pub(crate) fn supported_render_dtypes(&self) -> crate::RenderDtypeSupport {
1138        self.caps.render_dtypes
1139    }
1140}
1141
1142impl Drop for GLProcessorThreaded {
1143    fn drop(&mut self) {
1144        drop(self.sender.take());
1145        let _ = self.handle.take().and_then(|h| h.join().ok());
1146    }
1147}
1148
1149// `pbo_elem_count` / `pbo_shape` are pure (no GL), so they are unit-testable
1150// without a GPU. The overflow→None arm of `pbo_elem_count` guards against an
1151// undersized PBO allocation, so it is worth pinning explicitly.
1152#[cfg(test)]
1153#[cfg_attr(coverage_nightly, coverage(off))]
1154mod tests {
1155    use super::{pbo_elem_count, pbo_shape};
1156    use edgefirst_tensor::PixelFormat;
1157
1158    #[test]
1159    fn elem_count_per_format() {
1160        // Packed RGBA: w*h*4.
1161        assert_eq!(pbo_elem_count(8, 4, PixelFormat::Rgba), Some(8 * 4 * 4));
1162        // Packed RGB: w*h*3.
1163        assert_eq!(pbo_elem_count(8, 4, PixelFormat::Rgb), Some(8 * 4 * 3));
1164        // NV12 semiplanar: w*h*3/2.
1165        assert_eq!(pbo_elem_count(8, 4, PixelFormat::Nv12), Some(8 * 4 * 3 / 2));
1166        // NV16 semiplanar: w*h*2.
1167        assert_eq!(pbo_elem_count(8, 4, PixelFormat::Nv16), Some(8 * 4 * 2));
1168    }
1169
1170    #[test]
1171    fn elem_count_overflow_is_none() {
1172        // w*h already overflows usize → None (never a wrapped, undersized count).
1173        assert_eq!(pbo_elem_count(usize::MAX, 2, PixelFormat::Rgba), None);
1174        // w*h fits but *channels overflows → None.
1175        assert_eq!(pbo_elem_count(usize::MAX, 1, PixelFormat::Rgb), None);
1176    }
1177
1178    #[test]
1179    fn shape_per_format() {
1180        // Planar: [channels, height, width].
1181        assert_eq!(pbo_shape(8, 4, PixelFormat::PlanarRgb), vec![3, 4, 8]);
1182        // SemiPlanar NV12: [height*3/2, width].
1183        assert_eq!(pbo_shape(8, 4, PixelFormat::Nv12), vec![4 * 3 / 2, 8]);
1184        // SemiPlanar NV16: [height*2, width].
1185        assert_eq!(pbo_shape(8, 4, PixelFormat::Nv16), vec![4 * 2, 8]);
1186        // Packed: [height, width, channels].
1187        assert_eq!(pbo_shape(8, 4, PixelFormat::Rgba), vec![4, 8, 4]);
1188    }
1189}