Skip to main content

j2k_jpeg_metal/
lib.rs

1// SPDX-License-Identifier: Apache-2.0
2
3//! Apple Metal GPU-backed JPEG decode and encode adapters for `j2k-jpeg`.
4//!
5//! The crate exposes the same CPU-visible JPEG decode surface as
6//! `j2k-jpeg`, with optional Metal-resident surfaces and batch submission
7//! helpers on macOS. Non-macOS builds keep the public API available but return
8//! `Error::MetalUnavailable` for explicit Metal-only work.
9
10#![deny(unsafe_op_in_unsafe_fn)]
11#![warn(unreachable_pub)]
12
13#[cfg(target_os = "macos")]
14mod abi;
15mod batch;
16#[cfg(target_os = "macos")]
17mod buffers;
18#[cfg(target_os = "macos")]
19mod compute;
20mod encode;
21mod routing;
22mod session;
23/// Viewport planning and composition helpers for JPEG decode surfaces.
24pub mod viewport;
25
26use std::sync::Arc;
27#[cfg(target_os = "macos")]
28use std::sync::Mutex;
29#[cfg(target_os = "macos")]
30use std::sync::OnceLock;
31
32use j2k_core::{
33    copy_tight_pixels_to_strided_output, BackendKind, BackendRequest, BufferError, CodecError,
34    DecodeOutcome, DeviceMemoryRange, DeviceSubmission, DeviceSurface, Downscale, ImageCodec,
35    ImageDecode, ImageDecodeDevice, ImageDecodeSubmit, PixelFormat, Rect, TileBatchDecodeDevice,
36    TileBatchDecodeManyDevice, TileBatchDecodeSubmit,
37};
38use j2k_jpeg::{
39    adapter::{
40        build_fast420_packet, build_fast420_packet_for_decoder, build_fast422_packet,
41        build_fast422_packet_for_decoder, build_fast444_packet, build_fast444_packet_for_decoder,
42        decoder_bytes, JpegFast420PacketV1, JpegFast422PacketV1, JpegFast444PacketV1,
43    },
44    Decoder as CpuDecoder, DecoderContext as CpuDecoderContext, JpegError, JpegView,
45    ScratchPool as CpuScratchPool, Warning as CpuWarning,
46};
47use j2k_metal_support::{
48    cpu_host_route, metal_kernel_route, metal_unavailable_route, reject_explicit_metal_route,
49    reject_unsupported_backend_route, MetalRouteProfileLabels,
50};
51#[cfg(target_os = "macos")]
52use j2k_metal_support::{system_default_device, MetalSupportError};
53
54pub use encode::{
55    encode_jpeg_baseline_batch_from_metal_buffers, encode_jpeg_baseline_from_metal_buffer,
56    JpegBaselineMetalEncodeTile,
57};
58
59#[cfg(target_os = "macos")]
60use metal::foreign_types::ForeignType;
61#[cfg(target_os = "macos")]
62use metal::{
63    Buffer, BufferRef, CommandBuffer, Device, MTLPixelFormat, MTLResourceOptions, MTLStorageMode,
64    MTLTextureType, MTLTextureUsage, Texture, TextureDescriptor, TextureRef,
65};
66
67#[derive(Debug, thiserror::Error)]
68/// Errors returned by the Metal JPEG backend.
69pub enum Error {
70    /// Error returned by the CPU JPEG parser or fallback decoder.
71    #[error(transparent)]
72    Decode(#[from] JpegError),
73    /// Error returned while assembling a baseline JPEG encode result.
74    #[error(transparent)]
75    Encode(#[from] j2k_jpeg::JpegEncodeError),
76    /// Output buffer validation failed.
77    #[error(transparent)]
78    Buffer(#[from] BufferError),
79    /// The requested backend is not supported by this crate.
80    #[error("backend request {request:?} is not supported by j2k-jpeg-metal")]
81    UnsupportedBackend {
82        /// Backend requested by the caller.
83        request: BackendRequest,
84    },
85    /// A Metal-specific request is structurally unsupported.
86    #[error("unsupported JPEG Metal request: {reason}")]
87    UnsupportedMetalRequest {
88        /// Static reason describing the rejected request.
89        reason: &'static str,
90    },
91    /// Metal is not available on the current host.
92    #[error("Metal is unavailable on this host")]
93    MetalUnavailable,
94    /// Metal runtime creation or device setup failed.
95    #[error("Metal runtime error: {message}")]
96    MetalRuntime {
97        /// Runtime error message.
98        message: String,
99    },
100    /// Metal kernel launch, validation, or completion failed.
101    #[error("Metal kernel error: {message}")]
102    MetalKernel {
103        /// Kernel error message.
104        message: String,
105    },
106    /// Shared Metal backend state was poisoned by a prior panic.
107    #[error("Metal state `{state}` is poisoned")]
108    MetalStatePoisoned {
109        /// Name of the poisoned state.
110        state: &'static str,
111    },
112}
113
114impl CodecError for Error {
115    fn is_truncated(&self) -> bool {
116        matches!(self, Self::Decode(inner) if inner.is_truncated())
117    }
118
119    fn is_not_implemented(&self) -> bool {
120        matches!(self, Self::Decode(inner) if inner.is_not_implemented())
121    }
122
123    fn is_unsupported(&self) -> bool {
124        matches!(
125            self,
126            Self::UnsupportedBackend { .. }
127                | Self::MetalUnavailable
128                | Self::UnsupportedMetalRequest { .. }
129        ) || matches!(self, Self::Decode(inner) if inner.is_unsupported())
130    }
131
132    fn is_buffer_error(&self) -> bool {
133        matches!(self, Self::Buffer(_))
134            || matches!(self, Self::Decode(inner) if inner.is_buffer_error())
135    }
136}
137
138#[derive(Clone)]
139pub(crate) enum Storage {
140    Host(Vec<u8>),
141    #[cfg(target_os = "macos")]
142    Metal {
143        buffer: Buffer,
144        offset: usize,
145    },
146}
147
148#[derive(Debug, Clone, Copy, PartialEq, Eq)]
149/// Where a decoded surface is currently resident.
150pub enum SurfaceResidency {
151    /// Pixel bytes are resident in host memory.
152    Host,
153    /// Pixel bytes were produced directly by a Metal decode kernel.
154    MetalResidentDecode,
155    /// Pixel bytes were decoded on CPU and uploaded into a Metal buffer.
156    CpuStagedMetalUpload,
157}
158
159#[derive(Clone)]
160/// Decoded image surface returned by the JPEG Metal backend.
161pub struct Surface {
162    backend: BackendKind,
163    residency: SurfaceResidency,
164    dimensions: (u32, u32),
165    fmt: PixelFormat,
166    pitch_bytes: usize,
167    storage: Storage,
168}
169
170impl Surface {
171    /// Number of bytes between consecutive rows.
172    pub fn pitch_bytes(&self) -> usize {
173        self.pitch_bytes
174    }
175
176    /// Current residency for the surface bytes.
177    pub fn residency(&self) -> SurfaceResidency {
178        self.residency
179    }
180
181    /// Return the tightly packed surface bytes.
182    pub fn as_bytes(&self) -> &[u8] {
183        match &self.storage {
184            Storage::Host(bytes) => bytes,
185            #[cfg(target_os = "macos")]
186            Storage::Metal { buffer, offset } => {
187                let len = self.byte_len();
188                // SAFETY: Metal surface byte views are bounded by validated dimensions and formats.
189                unsafe {
190                    core::slice::from_raw_parts(buffer.contents().cast::<u8>().add(*offset), len)
191                }
192            }
193        }
194    }
195
196    /// Copy the tightly packed surface into a caller-provided strided buffer.
197    pub fn download_into(&self, out: &mut [u8], stride: usize) -> Result<(), Error> {
198        copy_tight_pixels_to_strided_output(self.as_bytes(), self.dimensions, self.fmt, out, stride)
199            .map_err(Error::from)
200    }
201
202    #[cfg(target_os = "macos")]
203    /// Return the Metal buffer and byte offset when the surface is Metal-backed.
204    pub fn metal_buffer(&self) -> Option<(&Buffer, usize)> {
205        match &self.storage {
206            Storage::Metal { buffer, offset } => Some((buffer, *offset)),
207            Storage::Host(_) => None,
208        }
209    }
210
211    #[cfg(target_os = "macos")]
212    pub(crate) fn from_metal_buffer(
213        buffer: Buffer,
214        dimensions: (u32, u32),
215        fmt: PixelFormat,
216    ) -> Self {
217        Self::from_metal_buffer_offset(buffer, dimensions, fmt, 0)
218    }
219
220    #[cfg(target_os = "macos")]
221    pub(crate) fn from_metal_buffer_offset(
222        buffer: Buffer,
223        dimensions: (u32, u32),
224        fmt: PixelFormat,
225        offset: usize,
226    ) -> Self {
227        Self {
228            backend: BackendKind::Metal,
229            residency: SurfaceResidency::MetalResidentDecode,
230            dimensions,
231            fmt,
232            pitch_bytes: dimensions.0 as usize * fmt.bytes_per_pixel(),
233            storage: Storage::Metal { buffer, offset },
234        }
235    }
236
237    #[cfg(target_os = "macos")]
238    pub(crate) fn from_cpu_staged_metal_buffer(
239        buffer: Buffer,
240        dimensions: (u32, u32),
241        fmt: PixelFormat,
242    ) -> Self {
243        Self::from_cpu_staged_metal_buffer_offset(buffer, dimensions, fmt, 0)
244    }
245
246    #[cfg(target_os = "macos")]
247    pub(crate) fn from_cpu_staged_metal_buffer_offset(
248        buffer: Buffer,
249        dimensions: (u32, u32),
250        fmt: PixelFormat,
251        offset: usize,
252    ) -> Self {
253        Self {
254            backend: BackendKind::Metal,
255            residency: SurfaceResidency::CpuStagedMetalUpload,
256            dimensions,
257            fmt,
258            pitch_bytes: dimensions.0 as usize * fmt.bytes_per_pixel(),
259            storage: Storage::Metal { buffer, offset },
260        }
261    }
262}
263
264impl DeviceSurface for Surface {
265    fn backend_kind(&self) -> BackendKind {
266        self.backend
267    }
268
269    fn residency(&self) -> j2k_core::SurfaceResidency {
270        match self.residency {
271            SurfaceResidency::Host => j2k_core::SurfaceResidency::Host,
272            SurfaceResidency::MetalResidentDecode => {
273                j2k_core::SurfaceResidency::MetalResidentDecode
274            }
275            SurfaceResidency::CpuStagedMetalUpload => {
276                j2k_core::SurfaceResidency::CpuStagedMetalUpload
277            }
278        }
279    }
280
281    fn dimensions(&self) -> (u32, u32) {
282        self.dimensions
283    }
284
285    fn pixel_format(&self) -> PixelFormat {
286        self.fmt
287    }
288
289    fn byte_len(&self) -> usize {
290        self.pitch_bytes * self.dimensions.1 as usize
291    }
292
293    fn memory_range(&self) -> Option<DeviceMemoryRange> {
294        match &self.storage {
295            Storage::Host(_) => None,
296            #[cfg(target_os = "macos")]
297            Storage::Metal { buffer, offset } => Some(DeviceMemoryRange::new(
298                BackendKind::Metal,
299                u64::try_from(buffer.as_ptr() as usize).ok()?,
300                *offset,
301                self.byte_len(),
302            )),
303        }
304    }
305}
306
307#[cfg(target_os = "macos")]
308#[doc(hidden)]
309#[derive(Clone)]
310pub struct ResidentPrivateJpegTile {
311    pub buffer: Buffer,
312    pub byte_offset: usize,
313    pub dimensions: (u32, u32),
314    pub pixel_format: PixelFormat,
315    pub pitch_bytes: usize,
316    pub status_buffer: Buffer,
317    pub command_buffer: CommandBuffer,
318}
319
320#[cfg(target_os = "macos")]
321#[derive(Clone)]
322/// Reusable caller-owned Metal buffer for full-tile JPEG batch output.
323pub struct MetalBatchOutputBuffer {
324    buffer: Buffer,
325    dimensions: (u32, u32),
326    fmt: PixelFormat,
327    pitch_bytes: usize,
328    tile_stride_bytes: usize,
329    tile_capacity: usize,
330}
331
332#[cfg(target_os = "macos")]
333impl MetalBatchOutputBuffer {
334    /// Allocate a reusable RGB8 output buffer for `tile_capacity` full-size tiles.
335    pub fn new_rgb8_tiles(
336        session: &MetalBackendSession,
337        dimensions: (u32, u32),
338        tile_capacity: usize,
339    ) -> Result<Self, Error> {
340        Self::new_tiles(session, dimensions, PixelFormat::Rgb8, tile_capacity)
341    }
342
343    /// Ensure this output buffer can hold `tile_capacity` RGB8 tiles with `dimensions`.
344    ///
345    /// The existing allocation is retained when it already has the requested
346    /// layout and at least the requested capacity. Otherwise the buffer is
347    /// replaced with a new allocation.
348    pub fn ensure_rgb8_tiles(
349        &mut self,
350        session: &MetalBackendSession,
351        dimensions: (u32, u32),
352        tile_capacity: usize,
353    ) -> Result<(), Error> {
354        if self.dimensions == dimensions
355            && self.fmt == PixelFormat::Rgb8
356            && self.tile_capacity >= tile_capacity
357        {
358            return Ok(());
359        }
360
361        *self = Self::new_rgb8_tiles(session, dimensions, tile_capacity)?;
362        Ok(())
363    }
364
365    /// Ensure this output buffer fits a full-image scaled RGB8 batch.
366    pub fn ensure_rgb8_scaled_tiles(
367        &mut self,
368        session: &MetalBackendSession,
369        full_dimensions: (u32, u32),
370        scale: Downscale,
371        tile_capacity: usize,
372    ) -> Result<(), Error> {
373        self.ensure_rgb8_tiles(session, scaled_dims(full_dimensions, scale), tile_capacity)
374    }
375
376    /// Ensure this output buffer fits a region-scaled RGB8 batch.
377    pub fn ensure_rgb8_region_scaled_tiles(
378        &mut self,
379        session: &MetalBackendSession,
380        roi: Rect,
381        scale: Downscale,
382        tile_capacity: usize,
383    ) -> Result<(), Error> {
384        let scaled = roi.scaled_covering(scale);
385        self.ensure_rgb8_tiles(session, (scaled.w, scaled.h), tile_capacity)
386    }
387
388    /// Ensure this output buffer fits a preflighted RGB8 Metal resident batch.
389    ///
390    /// Ineligible reports return an error without replacing the existing
391    /// allocation. Eligible empty reports are a no-op.
392    pub fn ensure_rgb8_batch_report(
393        &mut self,
394        session: &MetalBackendSession,
395        report: &JpegMetalResidentBatchReport,
396    ) -> Result<(), Error> {
397        let Some(dimensions) = report_required_output_dimensions(report)? else {
398            return Ok(());
399        };
400        self.ensure_rgb8_tiles(session, dimensions, report.required_tile_capacity())
401    }
402
403    fn new_tiles(
404        session: &MetalBackendSession,
405        dimensions: (u32, u32),
406        fmt: PixelFormat,
407        tile_capacity: usize,
408    ) -> Result<Self, Error> {
409        if dimensions.0 == 0 || dimensions.1 == 0 || tile_capacity == 0 {
410            return Err(Error::UnsupportedMetalRequest {
411                reason: "JPEG Metal batch output requires nonzero dimensions and tile capacity",
412            });
413        }
414        let row_bytes = dimensions
415            .0
416            .checked_mul(u32::try_from(fmt.bytes_per_pixel()).map_err(|_| {
417                BufferError::SizeOverflow {
418                    what: "JPEG Metal output row bytes",
419                }
420            })?)
421            .ok_or(BufferError::SizeOverflow {
422                what: "JPEG Metal output row bytes",
423            })? as usize;
424        let tile_stride_bytes =
425            row_bytes
426                .checked_mul(dimensions.1 as usize)
427                .ok_or(BufferError::SizeOverflow {
428                    what: "JPEG Metal output tile bytes",
429                })?;
430        let byte_len =
431            tile_stride_bytes
432                .checked_mul(tile_capacity)
433                .ok_or(BufferError::SizeOverflow {
434                    what: "JPEG Metal batch output bytes",
435                })?;
436        let byte_len_u64 = u64::try_from(byte_len).map_err(|_| BufferError::SizeOverflow {
437            what: "JPEG Metal batch output bytes",
438        })?;
439        let buffer = session
440            .device()
441            .new_buffer(byte_len_u64, MTLResourceOptions::StorageModeShared);
442        Ok(Self {
443            buffer,
444            dimensions,
445            fmt,
446            pitch_bytes: row_bytes,
447            tile_stride_bytes,
448            tile_capacity,
449        })
450    }
451
452    /// Backing Metal buffer.
453    pub fn buffer(&self) -> &BufferRef {
454        self.buffer.as_ref()
455    }
456
457    /// Tile dimensions for this output allocation.
458    pub fn dimensions(&self) -> (u32, u32) {
459        self.dimensions
460    }
461
462    /// Pixel format for this output allocation.
463    pub fn pixel_format(&self) -> PixelFormat {
464        self.fmt
465    }
466
467    /// Number of reusable tile slots in the buffer.
468    pub fn tile_capacity(&self) -> usize {
469        self.tile_capacity
470    }
471
472    /// Number of bytes between rows in one tile.
473    pub fn pitch_bytes(&self) -> usize {
474        self.pitch_bytes
475    }
476
477    /// Number of bytes reserved for each tile slot.
478    pub fn tile_stride_bytes(&self) -> usize {
479        self.tile_stride_bytes
480    }
481
482    /// Total byte length of the backing allocation.
483    pub fn byte_len(&self) -> usize {
484        self.tile_stride_bytes * self.tile_capacity
485    }
486
487    pub(crate) fn clone_buffer(&self) -> Buffer {
488        self.buffer.clone()
489    }
490}
491
492#[cfg(target_os = "macos")]
493#[derive(Clone)]
494/// Reusable caller-owned Metal textures for full-tile JPEG batch output.
495pub struct MetalBatchTextureOutput {
496    textures: Vec<Texture>,
497    dimensions: (u32, u32),
498    fmt: PixelFormat,
499    metal_fmt: MTLPixelFormat,
500}
501
502#[cfg(target_os = "macos")]
503impl MetalBatchTextureOutput {
504    /// Allocate reusable private RGBA8 textures for `tile_capacity` full-size tiles.
505    pub fn new_rgba8_tiles(
506        session: &MetalBackendSession,
507        dimensions: (u32, u32),
508        tile_capacity: usize,
509    ) -> Result<Self, Error> {
510        if dimensions.0 == 0 || dimensions.1 == 0 || tile_capacity == 0 {
511            return Err(Error::UnsupportedMetalRequest {
512                reason:
513                    "JPEG Metal batch texture output requires nonzero dimensions and tile capacity",
514            });
515        }
516
517        let descriptor = TextureDescriptor::new();
518        descriptor.set_texture_type(MTLTextureType::D2);
519        descriptor.set_pixel_format(MTLPixelFormat::RGBA8Unorm);
520        descriptor.set_width(u64::from(dimensions.0));
521        descriptor.set_height(u64::from(dimensions.1));
522        descriptor.set_depth(1);
523        descriptor.set_mipmap_level_count(1);
524        descriptor.set_sample_count(1);
525        descriptor.set_storage_mode(MTLStorageMode::Private);
526        descriptor.set_usage(MTLTextureUsage::ShaderRead | MTLTextureUsage::ShaderWrite);
527
528        let mut textures = Vec::with_capacity(tile_capacity);
529        for _ in 0..tile_capacity {
530            textures.push(session.device().new_texture(&descriptor));
531        }
532
533        Ok(Self {
534            textures,
535            dimensions,
536            fmt: PixelFormat::Rgba8,
537            metal_fmt: MTLPixelFormat::RGBA8Unorm,
538        })
539    }
540
541    /// Ensure this output set can hold `tile_capacity` RGBA8 textures with `dimensions`.
542    ///
543    /// Existing textures are retained when they already have the requested
544    /// layout and at least the requested capacity. Otherwise the texture set is
545    /// replaced with new private RGBA8 textures.
546    pub fn ensure_rgba8_tiles(
547        &mut self,
548        session: &MetalBackendSession,
549        dimensions: (u32, u32),
550        tile_capacity: usize,
551    ) -> Result<(), Error> {
552        if self.dimensions == dimensions
553            && self.fmt == PixelFormat::Rgba8
554            && self.metal_fmt == MTLPixelFormat::RGBA8Unorm
555            && self.tile_capacity() >= tile_capacity
556        {
557            return Ok(());
558        }
559
560        *self = Self::new_rgba8_tiles(session, dimensions, tile_capacity)?;
561        Ok(())
562    }
563
564    /// Ensure this output set fits a full-image scaled RGBA8 texture batch.
565    pub fn ensure_rgba8_scaled_tiles(
566        &mut self,
567        session: &MetalBackendSession,
568        full_dimensions: (u32, u32),
569        scale: Downscale,
570        tile_capacity: usize,
571    ) -> Result<(), Error> {
572        self.ensure_rgba8_tiles(session, scaled_dims(full_dimensions, scale), tile_capacity)
573    }
574
575    /// Ensure this output set fits a region-scaled RGBA8 texture batch.
576    pub fn ensure_rgba8_region_scaled_tiles(
577        &mut self,
578        session: &MetalBackendSession,
579        roi: Rect,
580        scale: Downscale,
581        tile_capacity: usize,
582    ) -> Result<(), Error> {
583        let scaled = roi.scaled_covering(scale);
584        self.ensure_rgba8_tiles(session, (scaled.w, scaled.h), tile_capacity)
585    }
586
587    /// Ensure this texture set fits a preflighted RGB8 Metal resident batch.
588    ///
589    /// Ineligible reports return an error without replacing the existing
590    /// textures. Eligible empty reports are a no-op.
591    pub fn ensure_rgba8_batch_report(
592        &mut self,
593        session: &MetalBackendSession,
594        report: &JpegMetalResidentBatchReport,
595    ) -> Result<(), Error> {
596        let Some(dimensions) = report_required_output_dimensions(report)? else {
597            return Ok(());
598        };
599        self.ensure_rgba8_tiles(session, dimensions, report.required_tile_capacity())
600    }
601
602    /// Tile dimensions for this output allocation.
603    pub fn dimensions(&self) -> (u32, u32) {
604        self.dimensions
605    }
606
607    /// Pixel format for this output allocation.
608    pub fn pixel_format(&self) -> PixelFormat {
609        self.fmt
610    }
611
612    /// Metal pixel format for each backing texture.
613    pub fn metal_pixel_format(&self) -> MTLPixelFormat {
614        self.metal_fmt
615    }
616
617    /// Number of reusable tile texture slots.
618    pub fn tile_capacity(&self) -> usize {
619        self.textures.len()
620    }
621
622    /// Return a reusable output texture by tile slot.
623    pub fn texture(&self, index: usize) -> Option<&TextureRef> {
624        self.textures.get(index).map(std::convert::AsRef::as_ref)
625    }
626
627    pub(crate) fn clone_texture(&self, index: usize) -> Option<Texture> {
628        self.textures.get(index).cloned()
629    }
630
631    pub(crate) fn clone_slots(&self, indices: &[usize]) -> Result<Self, Error> {
632        let mut textures = Vec::with_capacity(indices.len());
633        for &index in indices {
634            textures.push(
635                self.clone_texture(index)
636                    .ok_or_else(|| Error::MetalKernel {
637                        message: "JPEG Metal batch texture output slot was missing".to_string(),
638                    })?,
639            );
640        }
641        Ok(Self {
642            textures,
643            dimensions: self.dimensions,
644            fmt: self.fmt,
645            metal_fmt: self.metal_fmt,
646        })
647    }
648}
649
650#[cfg(target_os = "macos")]
651#[derive(Clone)]
652/// One decoded JPEG tile resident in a caller-owned Metal texture.
653pub struct MetalTextureTile {
654    texture: Texture,
655    dimensions: (u32, u32),
656    fmt: PixelFormat,
657}
658
659#[cfg(target_os = "macos")]
660impl MetalTextureTile {
661    pub(crate) fn new(texture: Texture, dimensions: (u32, u32), fmt: PixelFormat) -> Self {
662        Self {
663            texture,
664            dimensions,
665            fmt,
666        }
667    }
668
669    /// Backing Metal texture containing the decoded tile.
670    pub fn texture(&self) -> &TextureRef {
671        self.texture.as_ref()
672    }
673
674    /// Decoded tile dimensions.
675    pub fn dimensions(&self) -> (u32, u32) {
676        self.dimensions
677    }
678
679    /// Decoded tile pixel format.
680    pub fn pixel_format(&self) -> PixelFormat {
681        self.fmt
682    }
683}
684
685#[cfg(target_os = "macos")]
686#[derive(Clone)]
687/// Reusable Metal device session for decode and encode submissions.
688pub struct MetalBackendSession {
689    device: Device,
690    runtime: Arc<OnceLock<Result<compute::MetalRuntime, MetalSupportError>>>,
691}
692
693#[cfg(target_os = "macos")]
694impl MetalBackendSession {
695    /// Create a session bound to an existing Metal device.
696    pub fn new(device: Device) -> Self {
697        Self {
698            device,
699            runtime: Arc::new(OnceLock::new()),
700        }
701    }
702
703    /// Create a session from the system default Metal device.
704    pub fn system_default() -> Result<Self, Error> {
705        system_default_device()
706            .map(Self::new)
707            .map_err(|error| compute::runtime_initialization_error(&error))
708    }
709
710    /// Metal device used by this session.
711    pub fn device(&self) -> &metal::DeviceRef {
712        self.device.as_ref()
713    }
714}
715
716#[cfg(target_os = "macos")]
717impl j2k_core::AcceleratorSession for MetalBackendSession {
718    fn backend_kind(&self) -> BackendKind {
719        BackendKind::Metal
720    }
721}
722
723#[cfg(target_os = "macos")]
724impl core::fmt::Debug for MetalBackendSession {
725    fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
726        f.debug_struct("MetalBackendSession")
727            .field("device", &self.device.name())
728            .field("runtime_initialized", &self.runtime.get().is_some())
729            .finish()
730    }
731}
732
733#[cfg(not(target_os = "macos"))]
734#[derive(Clone, Copy, Debug, Default)]
735/// Placeholder Metal session for non-macOS builds.
736pub struct MetalBackendSession {
737    _private: (),
738}
739
740#[cfg(not(target_os = "macos"))]
741impl MetalBackendSession {
742    /// Return `Error::MetalUnavailable` on hosts without Metal support.
743    pub fn system_default() -> Result<Self, Error> {
744        Err(Error::MetalUnavailable)
745    }
746}
747
748#[derive(Default)]
749/// Shared batching session used by `JpegTileBatch` and submit APIs.
750pub struct MetalSession {
751    shared: session::SharedSession,
752}
753
754impl MetalSession {
755    /// Create a tile batching session that reuses an existing Metal backend session.
756    #[cfg(target_os = "macos")]
757    pub fn with_backend_session(backend_session: MetalBackendSession) -> Self {
758        Self {
759            shared: session::SharedSession(Arc::new(Mutex::new(
760                session::SessionState::with_backend_session(backend_session),
761            ))),
762        }
763    }
764
765    /// Number of Metal or emulated submissions flushed through this session.
766    pub fn submissions(&self) -> Result<u64, Error> {
767        Ok(self.shared.lock()?.submissions)
768    }
769}
770
771impl core::fmt::Debug for MetalSession {
772    fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
773        f.debug_struct("MetalSession")
774            .field("submissions", &self.submissions())
775            .finish()
776    }
777}
778
779/// Convenience wrapper for submitting a group of JPEG tiles to one decoder
780/// session.
781///
782/// The batch preserves submission order and lets compatible requests share a
783/// Metal submission. Callers still own slide metadata, level selection, cache
784/// policy, and viewport planning.
785#[derive(Default)]
786pub struct JpegTileBatch {
787    session: MetalSession,
788    submissions: Vec<batch::MetalSubmission>,
789}
790
791impl JpegTileBatch {
792    /// Create an empty tile batch.
793    pub fn new() -> Self {
794        Self::default()
795    }
796
797    /// Create an empty tile batch with capacity for `capacity` submissions.
798    pub fn with_capacity(capacity: usize) -> Self {
799        Self {
800            submissions: Vec::with_capacity(capacity),
801            ..Self::default()
802        }
803    }
804
805    /// Number of queued tile requests.
806    pub fn len(&self) -> usize {
807        self.submissions.len()
808    }
809
810    /// Whether the batch has no queued tile requests.
811    pub fn is_empty(&self) -> bool {
812        self.submissions.is_empty()
813    }
814
815    /// Number of Metal session submissions already flushed.
816    ///
817    /// Queued requests normally do not increment this until `decode_all` waits
818    /// on the first result.
819    pub fn submissions(&self) -> Result<u64, Error> {
820        self.session.submissions()
821    }
822
823    /// Queue a full-tile decode request, copying the compressed tile bytes into
824    /// the batch.
825    pub fn push_tile(
826        &mut self,
827        input: &[u8],
828        fmt: PixelFormat,
829        backend: BackendRequest,
830    ) -> Result<usize, Error> {
831        self.push_shared_tile(Arc::<[u8]>::from(input), fmt, backend)
832    }
833
834    /// Queue a full-tile decode request backed by shared compressed tile bytes.
835    pub fn push_shared_tile(
836        &mut self,
837        input: Arc<[u8]>,
838        fmt: PixelFormat,
839        backend: BackendRequest,
840    ) -> Result<usize, Error> {
841        self.push_shared_request(input, fmt, backend, batch::BatchOp::Full)
842    }
843
844    /// Queue a region decode request, copying the compressed tile bytes into
845    /// the batch.
846    pub fn push_tile_region(
847        &mut self,
848        input: &[u8],
849        fmt: PixelFormat,
850        roi: Rect,
851        backend: BackendRequest,
852    ) -> Result<usize, Error> {
853        self.push_shared_tile_region(Arc::<[u8]>::from(input), fmt, roi, backend)
854    }
855
856    /// Queue a region decode request backed by shared compressed tile bytes.
857    pub fn push_shared_tile_region(
858        &mut self,
859        input: Arc<[u8]>,
860        fmt: PixelFormat,
861        roi: Rect,
862        backend: BackendRequest,
863    ) -> Result<usize, Error> {
864        self.push_shared_request(input, fmt, backend, batch::BatchOp::Region(roi))
865    }
866
867    /// Queue a scaled decode request, copying the compressed tile bytes into
868    /// the batch.
869    pub fn push_tile_scaled(
870        &mut self,
871        input: &[u8],
872        fmt: PixelFormat,
873        scale: Downscale,
874        backend: BackendRequest,
875    ) -> Result<usize, Error> {
876        self.push_shared_tile_scaled(Arc::<[u8]>::from(input), fmt, scale, backend)
877    }
878
879    /// Queue a scaled decode request backed by shared compressed tile bytes.
880    pub fn push_shared_tile_scaled(
881        &mut self,
882        input: Arc<[u8]>,
883        fmt: PixelFormat,
884        scale: Downscale,
885        backend: BackendRequest,
886    ) -> Result<usize, Error> {
887        self.push_shared_request(input, fmt, backend, batch::BatchOp::Scaled(scale))
888    }
889
890    /// Queue a region decode at reduced resolution, copying the compressed tile
891    /// bytes into the batch.
892    pub fn push_tile_region_scaled(
893        &mut self,
894        input: &[u8],
895        fmt: PixelFormat,
896        roi: Rect,
897        scale: Downscale,
898        backend: BackendRequest,
899    ) -> Result<usize, Error> {
900        self.push_shared_tile_region_scaled(Arc::<[u8]>::from(input), fmt, roi, scale, backend)
901    }
902
903    /// Queue a region decode at reduced resolution backed by shared compressed
904    /// tile bytes.
905    pub fn push_shared_tile_region_scaled(
906        &mut self,
907        input: Arc<[u8]>,
908        fmt: PixelFormat,
909        roi: Rect,
910        scale: Downscale,
911        backend: BackendRequest,
912    ) -> Result<usize, Error> {
913        self.push_shared_request(
914            input,
915            fmt,
916            backend,
917            batch::BatchOp::RegionScaled { roi, scale },
918        )
919    }
920
921    /// Decode all queued tile requests and return surfaces in submission order.
922    pub fn decode_all(self) -> Result<Vec<Surface>, Error> {
923        let mut surfaces = Vec::with_capacity(self.submissions.len());
924        for submission in self.submissions {
925            surfaces.push(submission.wait()?);
926        }
927        Ok(surfaces)
928    }
929
930    fn push_shared_request(
931        &mut self,
932        input: Arc<[u8]>,
933        fmt: PixelFormat,
934        backend: BackendRequest,
935        op: batch::BatchOp,
936    ) -> Result<usize, Error> {
937        let slot = self.submissions.len();
938        let submission = {
939            let mut state = self.session.shared.lock()?;
940            let (fast444_packet, fast422_packet, fast420_packet) =
941                state.resolve_fast_packets(&input, backend);
942            let slot = state.queue_request(batch::QueuedRequest::new_shared(
943                input,
944                fmt,
945                backend,
946                op,
947                fast444_packet,
948                fast422_packet,
949                fast420_packet,
950            ));
951            batch::MetalSubmission {
952                session: self.session.shared.clone(),
953                slot,
954            }
955        };
956        self.submissions.push(submission);
957        Ok(slot)
958    }
959}
960
961/// JPEG decoder that can return host or Metal-resident surfaces.
962pub struct Decoder<'a> {
963    inner: CpuDecoder<'a>,
964    source: Arc<[u8]>,
965    fast444_packet: Option<Arc<JpegFast444PacketV1>>,
966    fast422_packet: Option<Arc<JpegFast422PacketV1>>,
967    fast420_packet: Option<Arc<JpegFast420PacketV1>>,
968}
969
970impl<'a> Decoder<'a> {
971    /// Parse a JPEG byte slice into a decoder with any available Metal packets.
972    pub fn new(input: &'a [u8]) -> Result<Self, Error> {
973        let inner = CpuDecoder::new(input)?;
974        Ok(Self {
975            fast444_packet: build_fast444_packet(input).ok().map(Arc::new),
976            fast422_packet: build_fast422_packet(input).ok().map(Arc::new),
977            fast420_packet: build_fast420_packet(input).ok().map(Arc::new),
978            inner,
979            source: Arc::<[u8]>::from(input),
980        })
981    }
982
983    /// Create a decoder from an already parsed JPEG view.
984    pub fn from_view(view: JpegView<'a>) -> Result<Self, Error> {
985        let inner = CpuDecoder::from_view(view)?;
986        let source = Arc::<[u8]>::from(decoder_bytes(&inner));
987        let fast444_packet = build_fast444_packet_for_decoder(&inner).ok().map(Arc::new);
988        let fast422_packet = build_fast422_packet_for_decoder(&inner).ok().map(Arc::new);
989        let fast420_packet = build_fast420_packet_for_decoder(&inner).ok().map(Arc::new);
990        Ok(Self {
991            inner,
992            source,
993            fast444_packet,
994            fast422_packet,
995            fast420_packet,
996        })
997    }
998
999    /// Borrow the underlying CPU JPEG decoder.
1000    pub fn inner(&self) -> &CpuDecoder<'a> {
1001        &self.inner
1002    }
1003
1004    #[cfg(target_os = "macos")]
1005    pub(crate) fn fast444_packet(&self) -> Option<&JpegFast444PacketV1> {
1006        self.fast444_packet.as_deref()
1007    }
1008
1009    #[cfg(target_os = "macos")]
1010    pub(crate) fn fast422_packet(&self) -> Option<&JpegFast422PacketV1> {
1011        self.fast422_packet.as_deref()
1012    }
1013
1014    #[cfg(target_os = "macos")]
1015    pub(crate) fn fast420_packet(&self) -> Option<&JpegFast420PacketV1> {
1016        self.fast420_packet.as_deref()
1017    }
1018
1019    #[cfg(target_os = "macos")]
1020    pub(crate) fn rgb8_region_scaled_metal_request(
1021        &self,
1022        roi: Rect,
1023        scale: Downscale,
1024    ) -> batch::QueuedRequest {
1025        self.rgb8_metal_request(batch::BatchOp::RegionScaled { roi, scale })
1026    }
1027
1028    #[cfg(target_os = "macos")]
1029    pub(crate) fn rgb8_metal_request(&self, op: batch::BatchOp) -> batch::QueuedRequest {
1030        batch::QueuedRequest::new_shared(
1031            Arc::clone(&self.source),
1032            PixelFormat::Rgb8,
1033            BackendRequest::Metal,
1034            op,
1035            self.fast444_packet.clone(),
1036            self.fast422_packet.clone(),
1037            self.fast420_packet.clone(),
1038        )
1039    }
1040
1041    /// Consume this wrapper and return the underlying CPU JPEG decoder.
1042    pub fn into_inner(self) -> CpuDecoder<'a> {
1043        self.inner
1044    }
1045
1046    /// Decode a region at the requested scale into a device surface when possible.
1047    pub fn decode_region_scaled_to_device(
1048        &mut self,
1049        fmt: PixelFormat,
1050        roi: Rect,
1051        scale: Downscale,
1052        backend: BackendRequest,
1053    ) -> Result<Surface, Error> {
1054        let mut pool = CpuScratchPool::new();
1055        decode_region_scaled_surface_from_decoder(
1056            &self.inner,
1057            &mut pool,
1058            fmt,
1059            roi,
1060            scale,
1061            backend,
1062            self.fast444_packet.as_deref(),
1063            self.fast422_packet.as_deref(),
1064            self.fast420_packet.as_deref(),
1065        )
1066    }
1067
1068    /// Decode a full image into a device surface using a reusable Metal session.
1069    pub fn decode_to_device_with_session(
1070        &mut self,
1071        fmt: PixelFormat,
1072        session: &MetalBackendSession,
1073    ) -> Result<Surface, Error> {
1074        #[cfg(target_os = "macos")]
1075        {
1076            let mut pool = CpuScratchPool::new();
1077            let decision = choose_route(
1078                &self.inner,
1079                BackendRequest::Metal,
1080                fmt,
1081                batch::BatchOp::Full,
1082                self.fast444_packet.as_deref(),
1083                self.fast422_packet.as_deref(),
1084                self.fast420_packet.as_deref(),
1085            );
1086            if let Some(err) = routing::decision_error(decision) {
1087                return Err(err);
1088            }
1089            match decision {
1090                routing::RouteDecision::MetalKernel => {
1091                    reject_cpu_staged_metal_upload(compute::decode_to_surface_with_session(
1092                        &self.inner,
1093                        &mut pool,
1094                        fmt,
1095                        self.fast444_packet.as_deref(),
1096                        self.fast422_packet.as_deref(),
1097                        self.fast420_packet.as_deref(),
1098                        session,
1099                    )?)
1100                }
1101                routing::RouteDecision::CpuHost
1102                | routing::RouteDecision::RejectExplicitMetal { .. }
1103                | routing::RouteDecision::RejectUnsupportedBackend { .. }
1104                | routing::RouteDecision::MetalUnavailable => unreachable!("handled above"),
1105            }
1106        }
1107        #[cfg(not(target_os = "macos"))]
1108        {
1109            let _ = session;
1110            let decision = choose_route(
1111                &self.inner,
1112                BackendRequest::Metal,
1113                fmt,
1114                batch::BatchOp::Full,
1115                self.fast444_packet.as_deref(),
1116                self.fast422_packet.as_deref(),
1117                self.fast420_packet.as_deref(),
1118            );
1119            if let Some(err) = routing::decision_error(decision) {
1120                return Err(err);
1121            }
1122            Err(Error::MetalUnavailable)
1123        }
1124    }
1125
1126    #[cfg(target_os = "macos")]
1127    #[doc(hidden)]
1128    pub fn decode_private_rgb8_tile_with_session(
1129        &mut self,
1130        session: &MetalBackendSession,
1131    ) -> Result<ResidentPrivateJpegTile, Error> {
1132        let decision = choose_route(
1133            &self.inner,
1134            BackendRequest::Metal,
1135            PixelFormat::Rgb8,
1136            batch::BatchOp::Full,
1137            self.fast444_packet.as_deref(),
1138            self.fast422_packet.as_deref(),
1139            self.fast420_packet.as_deref(),
1140        );
1141        if let Some(err) = routing::decision_error(decision) {
1142            return Err(err);
1143        }
1144        match decision {
1145            routing::RouteDecision::MetalKernel => compute::decode_private_rgb8_tile_with_session(
1146                &self.inner,
1147                self.fast444_packet.as_deref(),
1148                self.fast422_packet.as_deref(),
1149                self.fast420_packet.as_deref(),
1150                session,
1151            ),
1152            routing::RouteDecision::CpuHost
1153            | routing::RouteDecision::RejectExplicitMetal { .. }
1154            | routing::RouteDecision::RejectUnsupportedBackend { .. }
1155            | routing::RouteDecision::MetalUnavailable => unreachable!("handled above"),
1156        }
1157    }
1158}
1159
1160impl ImageCodec for Decoder<'_> {
1161    type Error = Error;
1162    type Warning = CpuWarning;
1163    type Pool = CpuScratchPool;
1164}
1165
1166impl<'a> ImageDecode<'a> for Decoder<'a> {
1167    type View = JpegView<'a>;
1168
1169    fn inspect(input: &'a [u8]) -> Result<j2k_core::Info, Self::Error> {
1170        Ok(CpuDecoder::inspect(input)?.to_core_info())
1171    }
1172
1173    fn parse(input: &'a [u8]) -> Result<Self::View, Self::Error> {
1174        Ok(JpegView::parse(input)?)
1175    }
1176
1177    fn from_view(view: Self::View) -> Result<Self, Self::Error> {
1178        Self::from_view(view)
1179    }
1180
1181    fn decode_into(
1182        &mut self,
1183        out: &mut [u8],
1184        stride: usize,
1185        fmt: PixelFormat,
1186    ) -> Result<DecodeOutcome<Self::Warning>, Self::Error> {
1187        Ok(self.inner.decode_into(out, stride, fmt)?.into())
1188    }
1189
1190    fn decode_into_with_scratch(
1191        &mut self,
1192        pool: &mut Self::Pool,
1193        out: &mut [u8],
1194        stride: usize,
1195        fmt: PixelFormat,
1196    ) -> Result<DecodeOutcome<Self::Warning>, Self::Error> {
1197        Ok(self
1198            .inner
1199            .decode_into_with_scratch(pool, out, stride, fmt)?
1200            .into())
1201    }
1202
1203    fn decode_region_into(
1204        &mut self,
1205        pool: &mut Self::Pool,
1206        out: &mut [u8],
1207        stride: usize,
1208        fmt: PixelFormat,
1209        roi: Rect,
1210    ) -> Result<DecodeOutcome<Self::Warning>, Self::Error> {
1211        Ok(self
1212            .inner
1213            .decode_region_into_with_scratch(pool, out, stride, fmt, roi.into())?
1214            .into())
1215    }
1216
1217    fn decode_scaled_into(
1218        &mut self,
1219        pool: &mut Self::Pool,
1220        out: &mut [u8],
1221        stride: usize,
1222        fmt: PixelFormat,
1223        scale: Downscale,
1224    ) -> Result<DecodeOutcome<Self::Warning>, Self::Error> {
1225        Ok(self
1226            .inner
1227            .decode_scaled_into_with_scratch(pool, out, stride, fmt, scale)?
1228            .into())
1229    }
1230
1231    fn decode_region_scaled_into(
1232        &mut self,
1233        pool: &mut Self::Pool,
1234        out: &mut [u8],
1235        stride: usize,
1236        fmt: PixelFormat,
1237        roi: Rect,
1238        scale: Downscale,
1239    ) -> Result<DecodeOutcome<Self::Warning>, Self::Error> {
1240        Ok(self
1241            .inner
1242            .decode_region_scaled_into_with_scratch(pool, out, stride, fmt, roi.into(), scale)?
1243            .into())
1244    }
1245}
1246
1247impl<'a> ImageDecodeDevice<'a> for Decoder<'a> {
1248    type DeviceSurface = Surface;
1249}
1250
1251#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
1252/// JPEG codec marker used by J2K's generic decode traits.
1253pub struct Codec;
1254
1255#[cfg(target_os = "macos")]
1256struct Rgb8MetalBatchPlan {
1257    requests: Vec<batch::QueuedRequest>,
1258    output_dimensions: Option<(u32, u32)>,
1259}
1260
1261#[cfg(target_os = "macos")]
1262#[derive(Debug, Clone, Copy, PartialEq, Eq)]
1263/// Preflight report for RGB8 JPEG Metal resident decoder batches.
1264pub struct JpegMetalResidentBatchReport {
1265    /// Requested decode operation.
1266    pub op: j2k_jpeg::JpegDecodeOp,
1267    /// Number of decoder tiles in the batch.
1268    pub tile_count: usize,
1269    /// Required output dimensions when the batch is eligible and shape-compatible.
1270    pub output_dimensions: Option<(u32, u32)>,
1271    /// Whether the batch can use reusable RGB8 Metal resident output.
1272    pub eligibility: j2k_jpeg::JpegBackendEligibility,
1273}
1274
1275#[cfg(target_os = "macos")]
1276impl JpegMetalResidentBatchReport {
1277    /// Required number of tile slots in caller-owned Metal output.
1278    #[must_use]
1279    pub fn required_tile_capacity(&self) -> usize {
1280        self.tile_count
1281    }
1282}
1283
1284#[cfg(target_os = "macos")]
1285fn report_required_output_dimensions(
1286    report: &JpegMetalResidentBatchReport,
1287) -> Result<Option<(u32, u32)>, Error> {
1288    if !report.eligibility.eligible {
1289        return Err(Error::UnsupportedMetalRequest {
1290            reason: report
1291                .eligibility
1292                .reason
1293                .unwrap_or("JPEG Metal resident batch report is not eligible"),
1294        });
1295    }
1296    if report.tile_count == 0 {
1297        return Ok(None);
1298    }
1299    report
1300        .output_dimensions
1301        .ok_or(Error::UnsupportedMetalRequest {
1302            reason: "JPEG Metal resident batch report is missing output dimensions",
1303        })
1304        .map(Some)
1305}
1306
1307#[cfg(target_os = "macos")]
1308fn rgb8_metal_output_dimensions_for_op(
1309    full_dimensions: (u32, u32),
1310    op: j2k_jpeg::JpegDecodeOp,
1311) -> Option<(u32, u32)> {
1312    match op {
1313        j2k_jpeg::JpegDecodeOp::Full => Some(full_dimensions),
1314        j2k_jpeg::JpegDecodeOp::Scaled(scale) => Some(scaled_dims(full_dimensions, scale)),
1315        j2k_jpeg::JpegDecodeOp::RegionScaled { roi, scale } => {
1316            let scaled = Rect {
1317                x: roi.x,
1318                y: roi.y,
1319                w: roi.w,
1320                h: roi.h,
1321            }
1322            .scaled_covering(scale);
1323            Some((scaled.w, scaled.h))
1324        }
1325        j2k_jpeg::JpegDecodeOp::Region(_) => None,
1326    }
1327}
1328
1329#[cfg(target_os = "macos")]
1330fn decoder_resident_sampling_family(decoder: &Decoder<'_>) -> batch::SamplingFamily {
1331    if decoder.fast420_packet().is_some() {
1332        batch::SamplingFamily::Fast420
1333    } else if decoder.fast422_packet().is_some() {
1334        batch::SamplingFamily::Fast422
1335    } else if decoder.fast444_packet().is_some() {
1336        batch::SamplingFamily::Fast444
1337    } else {
1338        batch::SamplingFamily::Other
1339    }
1340}
1341
1342#[cfg(target_os = "macos")]
1343fn decoder_resident_restart_interval_mcus(decoder: &Decoder<'_>) -> u32 {
1344    if let Some(packet) = decoder.fast420_packet() {
1345        packet.restart_interval_mcus
1346    } else if let Some(packet) = decoder.fast422_packet() {
1347        packet.restart_interval_mcus
1348    } else if let Some(packet) = decoder.fast444_packet() {
1349        packet.restart_interval_mcus
1350    } else {
1351        0
1352    }
1353}
1354
1355impl ImageCodec for Codec {
1356    type Error = Error;
1357    type Warning = CpuWarning;
1358    type Pool = CpuScratchPool;
1359}
1360
1361/// Inputs for a batched RGB8 Metal decode: raw JPEG bytes or pre-parsed
1362/// decoders that carry cached Metal fast-packet state.
1363#[cfg(target_os = "macos")]
1364#[derive(Clone, Copy)]
1365pub enum Rgb8MetalBatchSource<'a, 'b> {
1366    /// Raw JPEG byte streams, parsed per call.
1367    Bytes(&'a [&'a [u8]]),
1368    /// Already parsed `Decoder` wrappers; reuses their cached Metal
1369    /// fast-packet state when building the resident batch request.
1370    Decoders(&'a [&'a Decoder<'b>]),
1371}
1372
1373#[cfg(target_os = "macos")]
1374impl Rgb8MetalBatchSource<'_, '_> {
1375    fn is_empty(&self) -> bool {
1376        match self {
1377            Rgb8MetalBatchSource::Bytes(inputs) => inputs.is_empty(),
1378            Rgb8MetalBatchSource::Decoders(decoders) => decoders.is_empty(),
1379        }
1380    }
1381}
1382
1383/// Geometry op applied to every tile of a batched RGB8 Metal decode.
1384#[cfg(target_os = "macos")]
1385#[derive(Clone, Copy, Debug, PartialEq, Eq)]
1386pub enum Rgb8MetalBatchOp {
1387    /// Full-tile decode at native dimensions.
1388    Full,
1389    /// Whole-tile downscale (half, quarter, or eighth).
1390    Scaled(Downscale),
1391    /// Scaled decode of one region, shared by every tile in the batch.
1392    RegionScaled {
1393        /// Region of interest to decode from every source tile.
1394        roi: Rect,
1395        /// Downscale factor applied to the selected region.
1396        scale: Downscale,
1397    },
1398}
1399
1400/// A batched RGB8 Metal decode request: what to decode and how.
1401#[cfg(target_os = "macos")]
1402#[derive(Clone, Copy)]
1403pub struct Rgb8MetalBatchRequest<'a, 'b> {
1404    /// Source JPEG bytes or prepared decoders for the batch.
1405    pub source: Rgb8MetalBatchSource<'a, 'b>,
1406    /// Geometry operation applied to each source tile.
1407    pub op: Rgb8MetalBatchOp,
1408}
1409
1410/// Caller-owned Metal buffer target for a batched RGB8 decode.
1411#[cfg(target_os = "macos")]
1412pub enum MetalBufferBatchTarget<'a> {
1413    /// Reuse the buffer as-is; its shape must already fit the batch.
1414    Reusable(&'a MetalBatchOutputBuffer),
1415    /// Grow the buffer to fit the batch before decoding.
1416    Resizable(&'a mut MetalBatchOutputBuffer),
1417}
1418
1419/// Caller-owned Metal RGBA8 texture target for a batched RGB8 decode.
1420#[cfg(target_os = "macos")]
1421pub enum MetalTextureBatchTarget<'a> {
1422    /// Reuse the texture set as-is; its shape must already fit the batch.
1423    Reusable(&'a MetalBatchTextureOutput),
1424    /// Grow the texture set to fit the batch before decoding.
1425    Resizable(&'a mut MetalBatchTextureOutput),
1426}
1427
1428impl Codec {
1429    #[cfg(target_os = "macos")]
1430    /// Inspect a cached RGB8 decoder batch for reusable Metal resident output.
1431    ///
1432    /// The report exposes whether the batch is resident-output eligible and,
1433    /// when eligible, the exact output dimensions and tile capacity callers
1434    /// should allocate before dispatch.
1435    pub fn inspect_rgb8_decoder_batch_metal_output(
1436        decoders: &[&Decoder<'_>],
1437        op: j2k_jpeg::JpegDecodeOp,
1438    ) -> JpegMetalResidentBatchReport {
1439        if decoders.is_empty() {
1440            return JpegMetalResidentBatchReport {
1441                op,
1442                tile_count: 0,
1443                output_dimensions: None,
1444                eligibility: j2k_jpeg::JpegBackendEligibility {
1445                    eligible: true,
1446                    reason: None,
1447                },
1448            };
1449        }
1450
1451        let mut output_dimensions = None;
1452        let mut sampling_family = None;
1453        for decoder in decoders {
1454            let request = j2k_jpeg::JpegCapabilityRequest {
1455                op,
1456                fmt: PixelFormat::Rgb8,
1457            };
1458            let report = j2k_jpeg::JpegCapabilityReport::for_decoder(decoder.inner(), request);
1459            let eligibility = report.metal_resident_rgb8_batch_output();
1460            if !eligibility.eligible {
1461                return JpegMetalResidentBatchReport {
1462                    op,
1463                    tile_count: decoders.len(),
1464                    output_dimensions: None,
1465                    eligibility,
1466                };
1467            }
1468
1469            if decoder.fast444_packet().is_none()
1470                && decoder.fast422_packet().is_none()
1471                && decoder.fast420_packet().is_none()
1472            {
1473                return JpegMetalResidentBatchReport {
1474                    op,
1475                    tile_count: decoders.len(),
1476                    output_dimensions: None,
1477                    eligibility: j2k_jpeg::JpegBackendEligibility {
1478                        eligible: false,
1479                        reason: Some(
1480                            "JPEG Metal reusable resident batch output requires cached fast-packet state",
1481                        ),
1482                    },
1483                };
1484            }
1485
1486            let Some(dimensions) =
1487                rgb8_metal_output_dimensions_for_op(decoder.inner().info().dimensions, op)
1488            else {
1489                return JpegMetalResidentBatchReport {
1490                    op,
1491                    tile_count: decoders.len(),
1492                    output_dimensions: None,
1493                    eligibility,
1494                };
1495            };
1496            if let Some(first) = output_dimensions {
1497                if first != dimensions {
1498                    return JpegMetalResidentBatchReport {
1499                        op,
1500                        tile_count: decoders.len(),
1501                        output_dimensions: None,
1502                        eligibility: j2k_jpeg::JpegBackendEligibility {
1503                            eligible: false,
1504                            reason: Some(
1505                                "JPEG Metal reusable RGB8 batch output requires matching output dimensions",
1506                            ),
1507                        },
1508                    };
1509                }
1510            } else {
1511                output_dimensions = Some(dimensions);
1512            }
1513
1514            let decoder_sampling_family = decoder_resident_sampling_family(decoder);
1515            if let Some(first) = sampling_family {
1516                if first != decoder_sampling_family {
1517                    return JpegMetalResidentBatchReport {
1518                        op,
1519                        tile_count: decoders.len(),
1520                        output_dimensions: None,
1521                        eligibility: j2k_jpeg::JpegBackendEligibility {
1522                            eligible: false,
1523                            reason: Some(
1524                                "JPEG Metal reusable resident batch output requires one batch to use the same fast-packet sampling family",
1525                            ),
1526                        },
1527                    };
1528                }
1529            } else {
1530                sampling_family = Some(decoder_sampling_family);
1531            }
1532
1533            if op == j2k_jpeg::JpegDecodeOp::Full
1534                && matches!(
1535                    decoder_sampling_family,
1536                    batch::SamplingFamily::Fast422 | batch::SamplingFamily::Fast444
1537                )
1538                && decoder_resident_restart_interval_mcus(decoder) != 0
1539            {
1540                return JpegMetalResidentBatchReport {
1541                    op,
1542                    tile_count: decoders.len(),
1543                    output_dimensions: None,
1544                    eligibility: j2k_jpeg::JpegBackendEligibility {
1545                        eligible: false,
1546                        reason: Some(
1547                            "JPEG Metal reusable resident batch output does not support restart-coded full-tile 4:2:2 or 4:4:4 batches",
1548                        ),
1549                    },
1550                };
1551            }
1552        }
1553
1554        JpegMetalResidentBatchReport {
1555            op,
1556            tile_count: decoders.len(),
1557            output_dimensions,
1558            eligibility: j2k_jpeg::JpegBackendEligibility {
1559                eligible: true,
1560                reason: None,
1561            },
1562        }
1563    }
1564
1565    #[cfg(target_os = "macos")]
1566    fn observe_rgb8_batch_output_dimensions(
1567        first_output_dimensions: &mut Option<(u32, u32)>,
1568        output_dimensions: (u32, u32),
1569    ) -> Result<(), Error> {
1570        if let Some(first) = *first_output_dimensions {
1571            if first != output_dimensions {
1572                return Err(Error::UnsupportedMetalRequest {
1573                    reason:
1574                        "JPEG Metal reusable RGB8 batch output requires matching output dimensions",
1575                });
1576            }
1577        } else {
1578            *first_output_dimensions = Some(output_dimensions);
1579        }
1580        Ok(())
1581    }
1582
1583    #[cfg(target_os = "macos")]
1584    fn rgb8_metal_batch_requests(
1585        inputs: &[&[u8]],
1586        mut op_for_decoder: impl FnMut(&CpuDecoder<'_>) -> batch::BatchOp,
1587    ) -> Result<Vec<batch::QueuedRequest>, Error> {
1588        let plan = Self::rgb8_metal_batch_requests_with_output_dimensions(inputs, |decoder| {
1589            (op_for_decoder(decoder), decoder.info().dimensions)
1590        })?;
1591        Ok(plan.requests)
1592    }
1593
1594    #[cfg(target_os = "macos")]
1595    fn rgb8_metal_batch_requests_with_output_dimensions(
1596        inputs: &[&[u8]],
1597        mut op_and_dimensions_for_decoder: impl FnMut(&CpuDecoder<'_>) -> (batch::BatchOp, (u32, u32)),
1598    ) -> Result<Rgb8MetalBatchPlan, Error> {
1599        let mut state = session::SessionState::default();
1600        let mut requests = Vec::with_capacity(inputs.len());
1601        let mut first_output_dimensions = None;
1602        for input in inputs {
1603            let decoder = CpuDecoder::new(input)?;
1604            let (op, output_dimensions) = op_and_dimensions_for_decoder(&decoder);
1605            Self::observe_rgb8_batch_output_dimensions(
1606                &mut first_output_dimensions,
1607                output_dimensions,
1608            )?;
1609            let input = state.intern_input_slice(input);
1610            let (fast444_packet, fast422_packet, fast420_packet) =
1611                state.resolve_fast_packets(&input, BackendRequest::Metal);
1612            requests.push(batch::QueuedRequest::new_shared(
1613                input,
1614                PixelFormat::Rgb8,
1615                BackendRequest::Metal,
1616                op,
1617                fast444_packet,
1618                fast422_packet,
1619                fast420_packet,
1620            ));
1621        }
1622        Ok(Rgb8MetalBatchPlan {
1623            requests,
1624            output_dimensions: first_output_dimensions,
1625        })
1626    }
1627
1628    #[cfg(target_os = "macos")]
1629    fn rgb8_metal_decoder_batch_requests_with_output_dimensions(
1630        decoders: &[&Decoder<'_>],
1631        mut op_and_dimensions_for_decoder: impl FnMut(&Decoder<'_>) -> (batch::BatchOp, (u32, u32)),
1632    ) -> Result<Rgb8MetalBatchPlan, Error> {
1633        let mut requests = Vec::with_capacity(decoders.len());
1634        let mut first_output_dimensions = None;
1635        for decoder in decoders {
1636            let (op, output_dimensions) = op_and_dimensions_for_decoder(decoder);
1637            Self::observe_rgb8_batch_output_dimensions(
1638                &mut first_output_dimensions,
1639                output_dimensions,
1640            )?;
1641            requests.push(decoder.rgb8_metal_request(op));
1642        }
1643        Ok(Rgb8MetalBatchPlan {
1644            requests,
1645            output_dimensions: first_output_dimensions,
1646        })
1647    }
1648    #[cfg(target_os = "macos")]
1649    fn rgb8_batch_op_and_dimensions(
1650        op: Rgb8MetalBatchOp,
1651        dimensions: (u32, u32),
1652    ) -> (batch::BatchOp, (u32, u32)) {
1653        match op {
1654            Rgb8MetalBatchOp::Full => (batch::BatchOp::Full, dimensions),
1655            Rgb8MetalBatchOp::Scaled(scale) => {
1656                let (w, h) = dimensions;
1657                (
1658                    batch::BatchOp::RegionScaled {
1659                        roi: Rect { x: 0, y: 0, w, h },
1660                        scale,
1661                    },
1662                    scaled_dims((w, h), scale),
1663                )
1664            }
1665            Rgb8MetalBatchOp::RegionScaled { roi, scale } => {
1666                let scaled = roi.scaled_covering(scale);
1667                (
1668                    batch::BatchOp::RegionScaled { roi, scale },
1669                    (scaled.w, scaled.h),
1670                )
1671            }
1672        }
1673    }
1674
1675    #[cfg(target_os = "macos")]
1676    fn rgb8_batch_jpeg_decode_op(op: Rgb8MetalBatchOp) -> j2k_jpeg::JpegDecodeOp {
1677        match op {
1678            Rgb8MetalBatchOp::Full => j2k_jpeg::JpegDecodeOp::Full,
1679            Rgb8MetalBatchOp::Scaled(scale) => j2k_jpeg::JpegDecodeOp::Scaled(scale),
1680            Rgb8MetalBatchOp::RegionScaled { roi, scale } => j2k_jpeg::JpegDecodeOp::RegionScaled {
1681                roi: roi.into(),
1682                scale,
1683            },
1684        }
1685    }
1686
1687    #[cfg(target_os = "macos")]
1688    fn plan_rgb8_metal_batch(
1689        source: Rgb8MetalBatchSource<'_, '_>,
1690        op: Rgb8MetalBatchOp,
1691        track_output_dimensions: bool,
1692    ) -> Result<(Rgb8MetalBatchPlan, usize), Error> {
1693        match source {
1694            Rgb8MetalBatchSource::Bytes(inputs) => {
1695                if track_output_dimensions {
1696                    Self::rgb8_metal_batch_requests_with_output_dimensions(inputs, |decoder| {
1697                        Self::rgb8_batch_op_and_dimensions(op, decoder.info().dimensions)
1698                    })
1699                    .map(|plan| (plan, inputs.len()))
1700                } else {
1701                    Self::rgb8_metal_batch_requests(inputs, |decoder| {
1702                        Self::rgb8_batch_op_and_dimensions(op, decoder.info().dimensions).0
1703                    })
1704                    .map(|requests| {
1705                        (
1706                            Rgb8MetalBatchPlan {
1707                                requests,
1708                                output_dimensions: None,
1709                            },
1710                            inputs.len(),
1711                        )
1712                    })
1713                }
1714            }
1715            Rgb8MetalBatchSource::Decoders(decoders) => {
1716                Self::rgb8_metal_decoder_batch_requests_with_output_dimensions(
1717                    decoders,
1718                    |decoder| {
1719                        Self::rgb8_batch_op_and_dimensions(op, decoder.inner().info().dimensions)
1720                    },
1721                )
1722                .map(|plan| (plan, decoders.len()))
1723            }
1724        }
1725    }
1726
1727    #[cfg(target_os = "macos")]
1728    const fn rgb8_buffer_batch_unsupported_reason(op: Rgb8MetalBatchOp) -> &'static str {
1729        match op {
1730            Rgb8MetalBatchOp::Full => {
1731                "JPEG Metal reusable batch output currently supports batchable full-tile RGB8 fast 4:2:0, 4:2:2, or 4:4:4 inputs"
1732            }
1733            Rgb8MetalBatchOp::Scaled(_) => {
1734                "JPEG Metal reusable scaled batch output currently supports batchable RGB8 fast 4:2:0, 4:2:2, or 4:4:4 inputs with half, quarter, or eighth scaling"
1735            }
1736            Rgb8MetalBatchOp::RegionScaled { .. } => {
1737                "JPEG Metal reusable region-scaled batch output currently supports batchable RGB8 fast 4:2:0, 4:2:2, or 4:4:4 inputs with matching output shapes"
1738            }
1739        }
1740    }
1741
1742    #[cfg(target_os = "macos")]
1743    const fn rgb8_texture_batch_unsupported_reason(op: Rgb8MetalBatchOp) -> &'static str {
1744        match op {
1745            Rgb8MetalBatchOp::Full => {
1746                "JPEG Metal texture batch output currently supports batchable full-tile RGB8 fast 4:2:0, 4:2:2, or 4:4:4 inputs"
1747            }
1748            Rgb8MetalBatchOp::Scaled(_) => {
1749                "JPEG Metal texture scaled batch output currently supports batchable RGB8 fast 4:2:0, 4:2:2, or 4:4:4 inputs with half, quarter, or eighth scaling"
1750            }
1751            Rgb8MetalBatchOp::RegionScaled { .. } => {
1752                "JPEG Metal texture region-scaled batch output currently supports batchable RGB8 fast 4:2:0, 4:2:2, or 4:4:4 inputs with matching output shapes"
1753            }
1754        }
1755    }
1756
1757    #[cfg(target_os = "macos")]
1758    /// Decode a batched RGB8 JPEG request into a caller-owned Metal buffer.
1759    ///
1760    /// This is the single buffer-output entry point for full, scaled, and
1761    /// region-scaled batches sourced from raw bytes or pre-parsed decoders;
1762    /// `MetalBufferBatchTarget::Resizable` grows the buffer to fit before
1763    /// decoding.
1764    pub fn decode_rgb8_batch_into_buffer_with_session(
1765        request: Rgb8MetalBatchRequest<'_, '_>,
1766        target: MetalBufferBatchTarget<'_>,
1767        session: &MetalBackendSession,
1768    ) -> Result<Vec<Result<Surface, Error>>, Error> {
1769        if request.source.is_empty() {
1770            return Ok(Vec::new());
1771        }
1772
1773        let resizable = matches!(target, MetalBufferBatchTarget::Resizable(_));
1774        let (plan, tile_count) =
1775            Self::plan_rgb8_metal_batch(request.source, request.op, resizable)?;
1776        let output: &MetalBatchOutputBuffer = match target {
1777            MetalBufferBatchTarget::Reusable(output) => output,
1778            MetalBufferBatchTarget::Resizable(output) => {
1779                if let Rgb8MetalBatchSource::Decoders(decoders) = request.source {
1780                    let report = Self::inspect_rgb8_decoder_batch_metal_output(
1781                        decoders,
1782                        Self::rgb8_batch_jpeg_decode_op(request.op),
1783                    );
1784                    output.ensure_rgb8_batch_report(session, &report)?;
1785                }
1786                let Some(output_dimensions) = plan.output_dimensions else {
1787                    return Ok(Vec::new());
1788                };
1789                output.ensure_rgb8_tiles(session, output_dimensions, tile_count)?;
1790                output
1791            }
1792        };
1793
1794        let results = match request.op {
1795            Rgb8MetalBatchOp::Full => compute::decode_full_rgb8_batch_into_output_with_session(
1796                &plan.requests,
1797                output,
1798                session,
1799            )?,
1800            Rgb8MetalBatchOp::Scaled(_) | Rgb8MetalBatchOp::RegionScaled { .. } => {
1801                compute::decode_region_scaled_rgb8_batch_into_output_with_session(
1802                    &plan.requests,
1803                    output,
1804                    session,
1805                )?
1806            }
1807        };
1808        results.ok_or(Error::UnsupportedMetalRequest {
1809            reason: Self::rgb8_buffer_batch_unsupported_reason(request.op),
1810        })
1811    }
1812
1813    #[cfg(target_os = "macos")]
1814    /// Decode a batched RGB8 JPEG request into caller-owned Metal RGBA8 textures.
1815    ///
1816    /// This is the single texture-output entry point for full, scaled, and
1817    /// region-scaled batches sourced from raw bytes or pre-parsed decoders;
1818    /// `MetalTextureBatchTarget::Resizable` grows the texture set to fit
1819    /// before decoding.
1820    pub fn decode_rgb8_batch_into_textures_with_session(
1821        request: Rgb8MetalBatchRequest<'_, '_>,
1822        target: MetalTextureBatchTarget<'_>,
1823        session: &MetalBackendSession,
1824    ) -> Result<Vec<Result<MetalTextureTile, Error>>, Error> {
1825        if request.source.is_empty() {
1826            return Ok(Vec::new());
1827        }
1828
1829        let resizable = matches!(target, MetalTextureBatchTarget::Resizable(_));
1830        let (plan, tile_count) =
1831            Self::plan_rgb8_metal_batch(request.source, request.op, resizable)?;
1832        let output: &MetalBatchTextureOutput = match target {
1833            MetalTextureBatchTarget::Reusable(output) => output,
1834            MetalTextureBatchTarget::Resizable(output) => {
1835                if let Rgb8MetalBatchSource::Decoders(decoders) = request.source {
1836                    let report = Self::inspect_rgb8_decoder_batch_metal_output(
1837                        decoders,
1838                        Self::rgb8_batch_jpeg_decode_op(request.op),
1839                    );
1840                    output.ensure_rgba8_batch_report(session, &report)?;
1841                }
1842                let Some(output_dimensions) = plan.output_dimensions else {
1843                    return Ok(Vec::new());
1844                };
1845                output.ensure_rgba8_tiles(session, output_dimensions, tile_count)?;
1846                output
1847            }
1848        };
1849
1850        let results = match request.op {
1851            Rgb8MetalBatchOp::Full => compute::decode_full_rgb8_batch_into_textures_with_session(
1852                &plan.requests,
1853                output,
1854                session,
1855            )?,
1856            Rgb8MetalBatchOp::Scaled(_) | Rgb8MetalBatchOp::RegionScaled { .. } => {
1857                compute::decode_region_scaled_rgb8_batch_into_textures_with_session(
1858                    &plan.requests,
1859                    output,
1860                    session,
1861                )?
1862            }
1863        };
1864        results.ok_or(Error::UnsupportedMetalRequest {
1865            reason: Self::rgb8_texture_batch_unsupported_reason(request.op),
1866        })
1867    }
1868
1869    #[cfg(target_os = "macos")]
1870    /// Decode a full-tile RGB8 JPEG decoder batch into resizable caller-owned
1871    /// Metal RGBA8 textures.
1872    ///
1873    /// Convenience wrapper over [`Codec::decode_rgb8_batch_into_textures_with_session`]
1874    /// for the resident whole-slide tile path.
1875    pub fn decode_rgb8_decoder_batch_into_resizable_metal_textures_with_session(
1876        decoders: &[&Decoder<'_>],
1877        output: &mut MetalBatchTextureOutput,
1878        session: &MetalBackendSession,
1879    ) -> Result<Vec<Result<MetalTextureTile, Error>>, Error> {
1880        Self::decode_rgb8_batch_into_textures_with_session(
1881            Rgb8MetalBatchRequest {
1882                source: Rgb8MetalBatchSource::Decoders(decoders),
1883                op: Rgb8MetalBatchOp::Full,
1884            },
1885            MetalTextureBatchTarget::Resizable(output),
1886            session,
1887        )
1888    }
1889
1890    #[cfg(target_os = "macos")]
1891    /// Decode a region-scaled RGB8 JPEG batch into a resizable caller-owned
1892    /// Metal buffer.
1893    ///
1894    /// Convenience wrapper over [`Codec::decode_rgb8_batch_into_buffer_with_session`]
1895    /// for the viewport composition path.
1896    pub fn decode_rgb8_region_scaled_batch_into_resizable_metal_buffer_with_session(
1897        inputs: &[&[u8]],
1898        roi: Rect,
1899        scale: Downscale,
1900        output: &mut MetalBatchOutputBuffer,
1901        session: &MetalBackendSession,
1902    ) -> Result<Vec<Result<Surface, Error>>, Error> {
1903        Self::decode_rgb8_batch_into_buffer_with_session(
1904            Rgb8MetalBatchRequest {
1905                source: Rgb8MetalBatchSource::Bytes(inputs),
1906                op: Rgb8MetalBatchOp::RegionScaled { roi, scale },
1907            },
1908            MetalBufferBatchTarget::Resizable(output),
1909            session,
1910        )
1911    }
1912
1913    #[cfg(target_os = "macos")]
1914    /// Decode a region-scaled RGB8 JPEG batch into resizable caller-owned
1915    /// Metal RGBA8 textures.
1916    ///
1917    /// Convenience wrapper over [`Codec::decode_rgb8_batch_into_textures_with_session`]
1918    /// for the viewport composition path.
1919    pub fn decode_rgb8_region_scaled_batch_into_resizable_metal_textures_with_session(
1920        inputs: &[&[u8]],
1921        roi: Rect,
1922        scale: Downscale,
1923        output: &mut MetalBatchTextureOutput,
1924        session: &MetalBackendSession,
1925    ) -> Result<Vec<Result<MetalTextureTile, Error>>, Error> {
1926        Self::decode_rgb8_batch_into_textures_with_session(
1927            Rgb8MetalBatchRequest {
1928                source: Rgb8MetalBatchSource::Bytes(inputs),
1929                op: Rgb8MetalBatchOp::RegionScaled { roi, scale },
1930            },
1931            MetalTextureBatchTarget::Resizable(output),
1932            session,
1933        )
1934    }
1935
1936    #[allow(clippy::too_many_arguments)]
1937    /// Submit a scaled region tile decode into a reusable Metal session.
1938    pub fn submit_tile_region_scaled_to_device(
1939        ctx: &mut j2k_core::DecoderContext<CpuDecoderContext>,
1940        session: &mut MetalSession,
1941        pool: &mut CpuScratchPool,
1942        input: &[u8],
1943        fmt: PixelFormat,
1944        roi: Rect,
1945        scale: Downscale,
1946        backend: BackendRequest,
1947    ) -> Result<<Self as TileBatchDecodeSubmit>::SubmittedSurface, Error> {
1948        let _ = (ctx, pool);
1949        let slot = {
1950            let mut state = session.shared.lock()?;
1951            let input = state.intern_input_slice(input);
1952            let (fast444_packet, fast422_packet, fast420_packet) =
1953                state.resolve_fast_packets(&input, backend);
1954            state.queue_request(batch::QueuedRequest::new_shared(
1955                input,
1956                fmt,
1957                backend,
1958                batch::BatchOp::RegionScaled { roi, scale },
1959                fast444_packet,
1960                fast422_packet,
1961                fast420_packet,
1962            ))
1963        };
1964        Ok(batch::MetalSubmission {
1965            session: session.shared.clone(),
1966            slot,
1967        })
1968    }
1969}
1970
1971impl<'a> ImageDecodeSubmit<'a> for Decoder<'a> {
1972    type Session = MetalSession;
1973    type DeviceSurface = Surface;
1974    type SubmittedSurface = batch::MetalSubmission;
1975
1976    fn submit_to_device(
1977        &mut self,
1978        session: &mut Self::Session,
1979        fmt: PixelFormat,
1980        backend: BackendRequest,
1981    ) -> Result<Self::SubmittedSurface, Self::Error> {
1982        let fast444_packet = if matches!(backend, BackendRequest::Auto | BackendRequest::Metal) {
1983            self.fast444_packet.clone()
1984        } else {
1985            None
1986        };
1987        let fast422_packet = if matches!(backend, BackendRequest::Auto | BackendRequest::Metal) {
1988            self.fast422_packet.clone()
1989        } else {
1990            None
1991        };
1992        let fast420_packet = if matches!(backend, BackendRequest::Auto | BackendRequest::Metal) {
1993            self.fast420_packet.clone()
1994        } else {
1995            None
1996        };
1997        let slot = session
1998            .shared
1999            .lock()?
2000            .queue_request(batch::QueuedRequest::new_shared(
2001                Arc::clone(&self.source),
2002                fmt,
2003                backend,
2004                batch::BatchOp::Full,
2005                fast444_packet,
2006                fast422_packet,
2007                fast420_packet,
2008            ));
2009        Ok(batch::MetalSubmission {
2010            session: session.shared.clone(),
2011            slot,
2012        })
2013    }
2014
2015    fn submit_region_to_device(
2016        &mut self,
2017        session: &mut Self::Session,
2018        fmt: PixelFormat,
2019        roi: Rect,
2020        backend: BackendRequest,
2021    ) -> Result<Self::SubmittedSurface, Self::Error> {
2022        let fast444_packet = if matches!(backend, BackendRequest::Auto | BackendRequest::Metal) {
2023            self.fast444_packet.clone()
2024        } else {
2025            None
2026        };
2027        let fast422_packet = if matches!(backend, BackendRequest::Auto | BackendRequest::Metal) {
2028            self.fast422_packet.clone()
2029        } else {
2030            None
2031        };
2032        let fast420_packet = if matches!(backend, BackendRequest::Auto | BackendRequest::Metal) {
2033            self.fast420_packet.clone()
2034        } else {
2035            None
2036        };
2037        let slot = session
2038            .shared
2039            .lock()?
2040            .queue_request(batch::QueuedRequest::new_shared(
2041                Arc::clone(&self.source),
2042                fmt,
2043                backend,
2044                batch::BatchOp::Region(roi),
2045                fast444_packet,
2046                fast422_packet,
2047                fast420_packet,
2048            ));
2049        Ok(batch::MetalSubmission {
2050            session: session.shared.clone(),
2051            slot,
2052        })
2053    }
2054
2055    fn submit_scaled_to_device(
2056        &mut self,
2057        session: &mut Self::Session,
2058        fmt: PixelFormat,
2059        scale: Downscale,
2060        backend: BackendRequest,
2061    ) -> Result<Self::SubmittedSurface, Self::Error> {
2062        let fast444_packet = if matches!(backend, BackendRequest::Auto | BackendRequest::Metal) {
2063            self.fast444_packet.clone()
2064        } else {
2065            None
2066        };
2067        let fast422_packet = if matches!(backend, BackendRequest::Auto | BackendRequest::Metal) {
2068            self.fast422_packet.clone()
2069        } else {
2070            None
2071        };
2072        let fast420_packet = if matches!(backend, BackendRequest::Auto | BackendRequest::Metal) {
2073            self.fast420_packet.clone()
2074        } else {
2075            None
2076        };
2077        let slot = session
2078            .shared
2079            .lock()?
2080            .queue_request(batch::QueuedRequest::new_shared(
2081                Arc::clone(&self.source),
2082                fmt,
2083                backend,
2084                batch::BatchOp::Scaled(scale),
2085                fast444_packet,
2086                fast422_packet,
2087                fast420_packet,
2088            ));
2089        Ok(batch::MetalSubmission {
2090            session: session.shared.clone(),
2091            slot,
2092        })
2093    }
2094
2095    fn submit_region_scaled_to_device(
2096        &mut self,
2097        session: &mut Self::Session,
2098        fmt: PixelFormat,
2099        roi: Rect,
2100        scale: Downscale,
2101        backend: BackendRequest,
2102    ) -> Result<Self::SubmittedSurface, Self::Error> {
2103        let fast444_packet = if matches!(backend, BackendRequest::Auto | BackendRequest::Metal) {
2104            self.fast444_packet.clone()
2105        } else {
2106            None
2107        };
2108        let fast422_packet = if matches!(backend, BackendRequest::Auto | BackendRequest::Metal) {
2109            self.fast422_packet.clone()
2110        } else {
2111            None
2112        };
2113        let fast420_packet = if matches!(backend, BackendRequest::Auto | BackendRequest::Metal) {
2114            self.fast420_packet.clone()
2115        } else {
2116            None
2117        };
2118        let slot = session
2119            .shared
2120            .lock()?
2121            .queue_request(batch::QueuedRequest::new_shared(
2122                Arc::clone(&self.source),
2123                fmt,
2124                backend,
2125                batch::BatchOp::RegionScaled { roi, scale },
2126                fast444_packet,
2127                fast422_packet,
2128                fast420_packet,
2129            ));
2130        Ok(batch::MetalSubmission {
2131            session: session.shared.clone(),
2132            slot,
2133        })
2134    }
2135}
2136
2137impl TileBatchDecodeSubmit for Codec {
2138    type Context = CpuDecoderContext;
2139    type Session = MetalSession;
2140    type DeviceSurface = Surface;
2141    type SubmittedSurface = batch::MetalSubmission;
2142
2143    fn submit_tile_to_device(
2144        ctx: &mut j2k_core::DecoderContext<Self::Context>,
2145        session: &mut Self::Session,
2146        pool: &mut Self::Pool,
2147        input: &[u8],
2148        fmt: PixelFormat,
2149        backend: BackendRequest,
2150    ) -> Result<Self::SubmittedSurface, Self::Error> {
2151        let _ = (ctx, pool);
2152        let slot = {
2153            let mut state = session.shared.lock()?;
2154            let input = state.intern_input_slice(input);
2155            let (fast444_packet, fast422_packet, fast420_packet) =
2156                state.resolve_fast_packets(&input, backend);
2157            state.queue_request(batch::QueuedRequest::new_shared(
2158                input,
2159                fmt,
2160                backend,
2161                batch::BatchOp::Full,
2162                fast444_packet,
2163                fast422_packet,
2164                fast420_packet,
2165            ))
2166        };
2167        Ok(batch::MetalSubmission {
2168            session: session.shared.clone(),
2169            slot,
2170        })
2171    }
2172
2173    fn submit_tile_region_to_device(
2174        ctx: &mut j2k_core::DecoderContext<Self::Context>,
2175        session: &mut Self::Session,
2176        pool: &mut Self::Pool,
2177        input: &[u8],
2178        fmt: PixelFormat,
2179        roi: Rect,
2180        backend: BackendRequest,
2181    ) -> Result<Self::SubmittedSurface, Self::Error> {
2182        let _ = (ctx, pool);
2183        let slot = {
2184            let mut state = session.shared.lock()?;
2185            let input = state.intern_input_slice(input);
2186            let (fast444_packet, fast422_packet, fast420_packet) =
2187                state.resolve_fast_packets(&input, backend);
2188            state.queue_request(batch::QueuedRequest::new_shared(
2189                input,
2190                fmt,
2191                backend,
2192                batch::BatchOp::Region(roi),
2193                fast444_packet,
2194                fast422_packet,
2195                fast420_packet,
2196            ))
2197        };
2198        Ok(batch::MetalSubmission {
2199            session: session.shared.clone(),
2200            slot,
2201        })
2202    }
2203
2204    fn submit_tile_scaled_to_device(
2205        ctx: &mut j2k_core::DecoderContext<Self::Context>,
2206        session: &mut Self::Session,
2207        pool: &mut Self::Pool,
2208        input: &[u8],
2209        fmt: PixelFormat,
2210        scale: Downscale,
2211        backend: BackendRequest,
2212    ) -> Result<Self::SubmittedSurface, Self::Error> {
2213        let _ = (ctx, pool);
2214        let slot = {
2215            let mut state = session.shared.lock()?;
2216            let input = state.intern_input_slice(input);
2217            let (fast444_packet, fast422_packet, fast420_packet) =
2218                state.resolve_fast_packets(&input, backend);
2219            state.queue_request(batch::QueuedRequest::new_shared(
2220                input,
2221                fmt,
2222                backend,
2223                batch::BatchOp::Scaled(scale),
2224                fast444_packet,
2225                fast422_packet,
2226                fast420_packet,
2227            ))
2228        };
2229        Ok(batch::MetalSubmission {
2230            session: session.shared.clone(),
2231            slot,
2232        })
2233    }
2234
2235    fn submit_tile_region_scaled_to_device(
2236        ctx: &mut j2k_core::DecoderContext<Self::Context>,
2237        session: &mut Self::Session,
2238        pool: &mut Self::Pool,
2239        input: &[u8],
2240        fmt: PixelFormat,
2241        roi: Rect,
2242        scale: Downscale,
2243        backend: BackendRequest,
2244    ) -> Result<Self::SubmittedSurface, Self::Error> {
2245        Codec::submit_tile_region_scaled_to_device(
2246            ctx, session, pool, input, fmt, roi, scale, backend,
2247        )
2248    }
2249}
2250
2251impl TileBatchDecodeDevice for Codec {
2252    type Context = CpuDecoderContext;
2253    type DeviceSurface = Surface;
2254}
2255
2256impl TileBatchDecodeManyDevice for Codec {
2257    type Context = CpuDecoderContext;
2258    type DeviceSurface = Surface;
2259
2260    fn decode_tiles_to_device(
2261        ctx: &mut j2k_core::DecoderContext<Self::Context>,
2262        pool: &mut Self::Pool,
2263        inputs: &[&[u8]],
2264        fmt: PixelFormat,
2265        backend: BackendRequest,
2266    ) -> Result<Vec<Self::DeviceSurface>, Self::Error> {
2267        if inputs.is_empty() {
2268            return Ok(Vec::new());
2269        }
2270
2271        let mut session = MetalSession::default();
2272        let submissions = inputs
2273            .iter()
2274            .map(|input| {
2275                <Self as TileBatchDecodeSubmit>::submit_tile_to_device(
2276                    ctx,
2277                    &mut session,
2278                    pool,
2279                    input,
2280                    fmt,
2281                    backend,
2282                )
2283            })
2284            .collect::<Result<Vec<_>, _>>()?;
2285
2286        submissions
2287            .into_iter()
2288            .map(DeviceSubmission::wait)
2289            .collect()
2290    }
2291}
2292
2293pub(crate) fn decode_surface_from_bytes(
2294    input: &[u8],
2295    fmt: PixelFormat,
2296    backend: BackendRequest,
2297    op: batch::BatchOp,
2298    fast444_packet: Option<Arc<JpegFast444PacketV1>>,
2299    fast422_packet: Option<Arc<JpegFast422PacketV1>>,
2300    fast420_packet: Option<Arc<JpegFast420PacketV1>>,
2301) -> Result<Surface, Error> {
2302    let decoder = CpuDecoder::new(input)?;
2303    let mut pool = CpuScratchPool::new();
2304    let build_auto_packets =
2305        matches!(backend, BackendRequest::Auto) && decoder.info().restart_interval.is_some();
2306    let build_metal_packets = matches!(backend, BackendRequest::Metal);
2307    let fast444_packet = if build_auto_packets || build_metal_packets {
2308        fast444_packet.or_else(|| {
2309            build_fast444_packet_for_decoder(&decoder)
2310                .ok()
2311                .map(Arc::new)
2312        })
2313    } else {
2314        None
2315    };
2316    let fast422_packet = if build_auto_packets || build_metal_packets {
2317        fast422_packet.or_else(|| {
2318            build_fast422_packet_for_decoder(&decoder)
2319                .ok()
2320                .map(Arc::new)
2321        })
2322    } else {
2323        None
2324    };
2325    let fast420_packet = if build_auto_packets || build_metal_packets {
2326        fast420_packet.or_else(|| {
2327            build_fast420_packet_for_decoder(&decoder)
2328                .ok()
2329                .map(Arc::new)
2330        })
2331    } else {
2332        None
2333    };
2334    decode_surface_from_decoder(
2335        &decoder,
2336        &mut pool,
2337        fmt,
2338        backend,
2339        op,
2340        fast444_packet.as_deref(),
2341        fast422_packet.as_deref(),
2342        fast420_packet.as_deref(),
2343    )
2344}
2345
2346#[cfg(not(target_os = "macos"))]
2347#[allow(clippy::unnecessary_wraps)]
2348pub(crate) fn decode_compatible_batch(
2349    requests: &[batch::QueuedRequest],
2350) -> Result<Option<Vec<Result<Surface, Error>>>, Error> {
2351    let _ = requests;
2352    Ok(None)
2353}
2354
2355#[allow(clippy::unnecessary_wraps)]
2356pub(crate) fn decode_compatible_batch_with_session(
2357    requests: &[batch::QueuedRequest],
2358    session: &mut session::SessionState,
2359) -> Result<Option<Vec<Result<Surface, Error>>>, Error> {
2360    #[cfg(target_os = "macos")]
2361    {
2362        compute::decode_full_batch_to_surfaces_with_session_state(requests, session)
2363    }
2364    #[cfg(not(target_os = "macos"))]
2365    {
2366        let _ = session;
2367        decode_compatible_batch(requests)
2368    }
2369}
2370
2371#[cfg(target_os = "macos")]
2372#[doc(hidden)]
2373pub fn decode_rgb8_batch_to_device_with_session(
2374    inputs: &[&[u8]],
2375    session: &MetalBackendSession,
2376) -> Result<Option<Vec<Result<Surface, Error>>>, Error> {
2377    if inputs.len() < 2 {
2378        return Ok(None);
2379    }
2380
2381    let mut state = session::SessionState::default();
2382    let mut requests = Vec::with_capacity(inputs.len());
2383    for input in inputs {
2384        let input = state.intern_input_slice(input);
2385        let (fast444_packet, fast422_packet, fast420_packet) =
2386            state.resolve_fast_packets(&input, BackendRequest::Metal);
2387        requests.push(batch::QueuedRequest::new_shared(
2388            input,
2389            PixelFormat::Rgb8,
2390            BackendRequest::Metal,
2391            batch::BatchOp::Full,
2392            fast444_packet,
2393            fast422_packet,
2394            fast420_packet,
2395        ));
2396    }
2397
2398    compute::decode_full_batch_to_surfaces_with_session(&requests, session)
2399}
2400
2401#[allow(clippy::too_many_arguments)]
2402fn decode_surface_from_decoder(
2403    decoder: &CpuDecoder<'_>,
2404    pool: &mut CpuScratchPool,
2405    fmt: PixelFormat,
2406    backend: BackendRequest,
2407    op: batch::BatchOp,
2408    fast444_packet: Option<&JpegFast444PacketV1>,
2409    fast422_packet: Option<&JpegFast422PacketV1>,
2410    fast420_packet: Option<&JpegFast420PacketV1>,
2411) -> Result<Surface, Error> {
2412    match op {
2413        batch::BatchOp::Full => match backend {
2414            BackendRequest::Cpu => decode_full_cpu_upload(decoder, pool, fmt),
2415            BackendRequest::Auto | BackendRequest::Metal => {
2416                let decision = choose_route(
2417                    decoder,
2418                    backend,
2419                    fmt,
2420                    op,
2421                    fast444_packet,
2422                    fast422_packet,
2423                    fast420_packet,
2424                );
2425                if let Some(err) = routing::decision_error(decision) {
2426                    return Err(err);
2427                }
2428                match decision {
2429                    routing::RouteDecision::CpuHost => decode_full_cpu_upload(decoder, pool, fmt),
2430                    routing::RouteDecision::MetalKernel => {
2431                        #[cfg(target_os = "macos")]
2432                        {
2433                            reject_cpu_staged_metal_upload(compute::decode_to_surface(
2434                                decoder,
2435                                pool,
2436                                fmt,
2437                                fast444_packet,
2438                                fast422_packet,
2439                                fast420_packet,
2440                            )?)
2441                        }
2442                        #[cfg(not(target_os = "macos"))]
2443                        {
2444                            let _ = (
2445                                decoder,
2446                                pool,
2447                                fmt,
2448                                fast444_packet,
2449                                fast422_packet,
2450                                fast420_packet,
2451                            );
2452                            Err(Error::MetalUnavailable)
2453                        }
2454                    }
2455                    routing::RouteDecision::RejectExplicitMetal { .. }
2456                    | routing::RouteDecision::RejectUnsupportedBackend { .. }
2457                    | routing::RouteDecision::MetalUnavailable => unreachable!("handled above"),
2458                }
2459            }
2460            BackendRequest::Cuda => Err(Error::UnsupportedBackend { request: backend }),
2461        },
2462        batch::BatchOp::Region(roi) => match backend {
2463            BackendRequest::Cpu => decode_region_cpu_upload(decoder, pool, fmt, roi),
2464            BackendRequest::Auto | BackendRequest::Metal => {
2465                let decision = choose_route(
2466                    decoder,
2467                    backend,
2468                    fmt,
2469                    op,
2470                    fast444_packet,
2471                    fast422_packet,
2472                    fast420_packet,
2473                );
2474                if let Some(err) = routing::decision_error(decision) {
2475                    return Err(err);
2476                }
2477                match decision {
2478                    routing::RouteDecision::CpuHost => {
2479                        decode_region_cpu_upload(decoder, pool, fmt, roi)
2480                    }
2481                    routing::RouteDecision::MetalKernel => {
2482                        #[cfg(target_os = "macos")]
2483                        {
2484                            reject_cpu_staged_metal_upload(compute::decode_region_to_surface(
2485                                decoder,
2486                                pool,
2487                                fmt,
2488                                roi.into(),
2489                                fast444_packet,
2490                                fast422_packet,
2491                                fast420_packet,
2492                            )?)
2493                        }
2494                        #[cfg(not(target_os = "macos"))]
2495                        {
2496                            let _ = (
2497                                decoder,
2498                                pool,
2499                                fmt,
2500                                roi,
2501                                fast444_packet,
2502                                fast422_packet,
2503                                fast420_packet,
2504                            );
2505                            Err(Error::MetalUnavailable)
2506                        }
2507                    }
2508                    routing::RouteDecision::RejectExplicitMetal { .. }
2509                    | routing::RouteDecision::RejectUnsupportedBackend { .. }
2510                    | routing::RouteDecision::MetalUnavailable => unreachable!("handled above"),
2511                }
2512            }
2513            BackendRequest::Cuda => Err(Error::UnsupportedBackend { request: backend }),
2514        },
2515        batch::BatchOp::Scaled(scale) => match backend {
2516            BackendRequest::Cpu => decode_scaled_cpu_upload(decoder, pool, fmt, scale),
2517            BackendRequest::Auto | BackendRequest::Metal => {
2518                let decision = choose_route(
2519                    decoder,
2520                    backend,
2521                    fmt,
2522                    op,
2523                    fast444_packet,
2524                    fast422_packet,
2525                    fast420_packet,
2526                );
2527                if let Some(err) = routing::decision_error(decision) {
2528                    return Err(err);
2529                }
2530                match decision {
2531                    routing::RouteDecision::CpuHost => {
2532                        decode_scaled_cpu_upload(decoder, pool, fmt, scale)
2533                    }
2534                    routing::RouteDecision::MetalKernel => {
2535                        #[cfg(target_os = "macos")]
2536                        {
2537                            reject_cpu_staged_metal_upload(compute::decode_scaled_to_surface(
2538                                decoder,
2539                                pool,
2540                                fmt,
2541                                scale,
2542                                fast444_packet,
2543                                fast422_packet,
2544                                fast420_packet,
2545                            )?)
2546                        }
2547                        #[cfg(not(target_os = "macos"))]
2548                        {
2549                            let _ = (
2550                                decoder,
2551                                pool,
2552                                fmt,
2553                                scale,
2554                                fast444_packet,
2555                                fast422_packet,
2556                                fast420_packet,
2557                            );
2558                            Err(Error::MetalUnavailable)
2559                        }
2560                    }
2561                    routing::RouteDecision::RejectExplicitMetal { .. }
2562                    | routing::RouteDecision::RejectUnsupportedBackend { .. }
2563                    | routing::RouteDecision::MetalUnavailable => unreachable!("handled above"),
2564                }
2565            }
2566            BackendRequest::Cuda => Err(Error::UnsupportedBackend { request: backend }),
2567        },
2568        batch::BatchOp::RegionScaled { roi, scale } => decode_region_scaled_surface_from_decoder(
2569            decoder,
2570            pool,
2571            fmt,
2572            roi,
2573            scale,
2574            backend,
2575            fast444_packet,
2576            fast422_packet,
2577            fast420_packet,
2578        ),
2579    }
2580}
2581
2582fn decode_full_cpu_upload(
2583    decoder: &CpuDecoder<'_>,
2584    pool: &mut CpuScratchPool,
2585    fmt: PixelFormat,
2586) -> Result<Surface, Error> {
2587    let dims = decoder.info().dimensions;
2588    let stride = dims.0 as usize * fmt.bytes_per_pixel();
2589    let mut out = vec![0u8; stride * dims.1 as usize];
2590    decoder.decode_into_with_scratch(pool, &mut out, stride, fmt)?;
2591    upload_surface(out, dims, fmt, BackendRequest::Cpu)
2592}
2593
2594fn decode_region_cpu_upload(
2595    decoder: &CpuDecoder<'_>,
2596    pool: &mut CpuScratchPool,
2597    fmt: PixelFormat,
2598    roi: Rect,
2599) -> Result<Surface, Error> {
2600    let dims = (roi.w, roi.h);
2601    let stride = dims.0 as usize * fmt.bytes_per_pixel();
2602    let mut out = vec![0u8; stride * dims.1 as usize];
2603    decoder.decode_region_into_with_scratch(pool, &mut out, stride, fmt, roi.into())?;
2604    upload_surface(out, dims, fmt, BackendRequest::Cpu)
2605}
2606
2607fn decode_scaled_cpu_upload(
2608    decoder: &CpuDecoder<'_>,
2609    pool: &mut CpuScratchPool,
2610    fmt: PixelFormat,
2611    scale: Downscale,
2612) -> Result<Surface, Error> {
2613    let dims = scaled_dims(decoder.info().dimensions, scale);
2614    let stride = dims.0 as usize * fmt.bytes_per_pixel();
2615    let mut out = vec![0u8; stride * dims.1 as usize];
2616    decoder.decode_scaled_into_with_scratch(pool, &mut out, stride, fmt, scale)?;
2617    upload_surface(out, dims, fmt, BackendRequest::Cpu)
2618}
2619
2620#[allow(clippy::too_many_arguments)]
2621fn decode_region_scaled_surface_from_decoder(
2622    decoder: &CpuDecoder<'_>,
2623    pool: &mut CpuScratchPool,
2624    fmt: PixelFormat,
2625    roi: Rect,
2626    scale: Downscale,
2627    backend: BackendRequest,
2628    fast444_packet: Option<&JpegFast444PacketV1>,
2629    fast422_packet: Option<&JpegFast422PacketV1>,
2630    fast420_packet: Option<&JpegFast420PacketV1>,
2631) -> Result<Surface, Error> {
2632    match backend {
2633        BackendRequest::Cpu => {
2634            decode_region_scaled_cpu_upload(decoder, pool, fmt, roi, scale, BackendRequest::Cpu)
2635        }
2636        BackendRequest::Auto | BackendRequest::Metal => {
2637            let decision = choose_route(
2638                decoder,
2639                backend,
2640                fmt,
2641                batch::BatchOp::RegionScaled { roi, scale },
2642                fast444_packet,
2643                fast422_packet,
2644                fast420_packet,
2645            );
2646            if let Some(err) = routing::decision_error(decision) {
2647                return Err(err);
2648            }
2649            match decision {
2650                routing::RouteDecision::CpuHost => decode_region_scaled_cpu_upload(
2651                    decoder,
2652                    pool,
2653                    fmt,
2654                    roi,
2655                    scale,
2656                    BackendRequest::Cpu,
2657                ),
2658                routing::RouteDecision::MetalKernel => {
2659                    #[cfg(target_os = "macos")]
2660                    {
2661                        reject_cpu_staged_metal_upload(compute::decode_region_scaled_to_surface(
2662                            decoder,
2663                            pool,
2664                            fmt,
2665                            roi.into(),
2666                            scale,
2667                            fast444_packet,
2668                            fast422_packet,
2669                            fast420_packet,
2670                        )?)
2671                    }
2672                    #[cfg(not(target_os = "macos"))]
2673                    {
2674                        let _ = (
2675                            decoder,
2676                            pool,
2677                            fmt,
2678                            roi,
2679                            scale,
2680                            fast444_packet,
2681                            fast422_packet,
2682                            fast420_packet,
2683                        );
2684                        Err(Error::MetalUnavailable)
2685                    }
2686                }
2687                routing::RouteDecision::RejectExplicitMetal { .. }
2688                | routing::RouteDecision::RejectUnsupportedBackend { .. }
2689                | routing::RouteDecision::MetalUnavailable => unreachable!("handled above"),
2690            }
2691        }
2692        BackendRequest::Cuda => Err(Error::UnsupportedBackend { request: backend }),
2693    }
2694}
2695
2696#[cfg(target_os = "macos")]
2697fn reject_cpu_staged_metal_upload(surface: Surface) -> Result<Surface, Error> {
2698    if surface.residency() == SurfaceResidency::CpuStagedMetalUpload {
2699        return Err(Error::UnsupportedMetalRequest {
2700            reason: "JPEG Metal explicit device decode requires a direct resident Metal decode; use the CPU path for CPU-staged output",
2701        });
2702    }
2703    Ok(surface)
2704}
2705
2706#[allow(clippy::too_many_arguments)]
2707fn choose_route(
2708    decoder: &CpuDecoder<'_>,
2709    backend: BackendRequest,
2710    fmt: PixelFormat,
2711    op: batch::BatchOp,
2712    fast444_packet: Option<&JpegFast444PacketV1>,
2713    fast422_packet: Option<&JpegFast422PacketV1>,
2714    fast420_packet: Option<&JpegFast420PacketV1>,
2715) -> routing::RouteDecision {
2716    let capabilities = routing::JpegMetalCapabilities::for_request(
2717        decoder,
2718        fmt,
2719        op,
2720        fast444_packet,
2721        fast422_packet,
2722        fast420_packet,
2723    );
2724    let decision = routing::decide_route(backend, capabilities);
2725    if j2k_profile::gpu_route_profile_enabled() {
2726        let request_s = format!("{backend:?}");
2727        let fmt_s = format!("{fmt:?}");
2728        let has_fast_packet_s = capabilities.has_fast_packet().to_string();
2729        let supports_format_s = capabilities.supports_output_format().to_string();
2730        let labels = jpeg_route_decision_profile(decision);
2731        j2k_profile::emit_gpu_route_profile(
2732            "jpeg",
2733            "metal",
2734            &[
2735                ("request", request_s.as_str()),
2736                ("fmt", fmt_s.as_str()),
2737                ("op", jpeg_batch_op_profile(op)),
2738                ("has_fast_packet", has_fast_packet_s.as_str()),
2739                ("supports_output_format", supports_format_s.as_str()),
2740                ("decision", labels.decision),
2741                ("reason", labels.reason),
2742            ],
2743        );
2744    }
2745    decision
2746}
2747
2748fn jpeg_batch_op_profile(op: batch::BatchOp) -> &'static str {
2749    match op {
2750        batch::BatchOp::Full => "full",
2751        batch::BatchOp::Region(_) => "region",
2752        batch::BatchOp::Scaled(_) => "scaled",
2753        batch::BatchOp::RegionScaled { .. } => "region_scaled",
2754    }
2755}
2756
2757fn jpeg_route_decision_profile(decision: routing::RouteDecision) -> MetalRouteProfileLabels {
2758    match decision {
2759        routing::RouteDecision::CpuHost => cpu_host_route(),
2760        routing::RouteDecision::MetalKernel => metal_kernel_route(),
2761        routing::RouteDecision::RejectExplicitMetal { reason } => {
2762            let reason_code = if reason.contains("fast") {
2763                "no_fast_packet"
2764            } else {
2765                "unsupported_format"
2766            };
2767            reject_explicit_metal_route(reason_code)
2768        }
2769        routing::RouteDecision::RejectUnsupportedBackend { .. } => {
2770            reject_unsupported_backend_route()
2771        }
2772        routing::RouteDecision::MetalUnavailable => metal_unavailable_route(),
2773    }
2774}
2775
2776fn decode_region_scaled_cpu_upload(
2777    decoder: &CpuDecoder<'_>,
2778    pool: &mut CpuScratchPool,
2779    fmt: PixelFormat,
2780    roi: Rect,
2781    scale: Downscale,
2782    backend: BackendRequest,
2783) -> Result<Surface, Error> {
2784    let scaled = roi.scaled_covering(scale);
2785    let dims = (scaled.w, scaled.h);
2786    let stride = dims.0 as usize * fmt.bytes_per_pixel();
2787    let mut out = vec![0u8; stride * dims.1 as usize];
2788    decoder.decode_region_scaled_into_with_scratch(
2789        pool,
2790        &mut out,
2791        stride,
2792        fmt,
2793        roi.into(),
2794        scale,
2795    )?;
2796    upload_surface(out, dims, fmt, backend)
2797}
2798
2799fn scaled_dims(full: (u32, u32), scale: Downscale) -> (u32, u32) {
2800    (
2801        full.0.div_ceil(scale.denominator()),
2802        full.1.div_ceil(scale.denominator()),
2803    )
2804}
2805
2806pub(crate) fn upload_surface(
2807    bytes: Vec<u8>,
2808    dimensions: (u32, u32),
2809    fmt: PixelFormat,
2810    backend: BackendRequest,
2811) -> Result<Surface, Error> {
2812    let pitch_bytes = dimensions.0 as usize * fmt.bytes_per_pixel();
2813    match backend {
2814        BackendRequest::Cpu => Ok(Surface {
2815            backend: BackendKind::Cpu,
2816            residency: SurfaceResidency::Host,
2817            dimensions,
2818            fmt,
2819            pitch_bytes,
2820            storage: Storage::Host(bytes),
2821        }),
2822        BackendRequest::Auto | BackendRequest::Metal => {
2823            #[cfg(target_os = "macos")]
2824            {
2825                let device = Device::system_default().ok_or(Error::MetalUnavailable)?;
2826                let buffer = device.new_buffer_with_data(
2827                    bytes.as_ptr().cast(),
2828                    bytes.len() as u64,
2829                    MTLResourceOptions::StorageModeShared,
2830                );
2831                Ok(Surface {
2832                    backend: BackendKind::Metal,
2833                    residency: SurfaceResidency::CpuStagedMetalUpload,
2834                    dimensions,
2835                    fmt,
2836                    pitch_bytes,
2837                    storage: Storage::Metal { buffer, offset: 0 },
2838                })
2839            }
2840            #[cfg(not(target_os = "macos"))]
2841            {
2842                if matches!(backend, BackendRequest::Auto) {
2843                    Ok(Surface {
2844                        backend: BackendKind::Cpu,
2845                        residency: SurfaceResidency::Host,
2846                        dimensions,
2847                        fmt,
2848                        pitch_bytes,
2849                        storage: Storage::Host(bytes),
2850                    })
2851                } else {
2852                    Err(Error::MetalUnavailable)
2853                }
2854            }
2855        }
2856        BackendRequest::Cuda => Err(Error::UnsupportedBackend { request: backend }),
2857    }
2858}
2859
2860pub use j2k_jpeg::{
2861    DecoderContext, Downscale as JpegDownscale, PixelFormat as JpegPixelFormat, ScratchPool,
2862};
2863pub use j2k_jpeg::{Info, Rect as JpegRectPublic};
2864
2865#[cfg(test)]
2866mod tests {
2867    use super::*;
2868
2869    // Shims over the collapsed batch API so every legacy entry shape
2870    // (source x op x target) keeps device coverage.
2871    #[cfg(target_os = "macos")]
2872    fn decode_rgb8_batch_into_metal_buffer_with_session(
2873        inputs: &[&[u8]],
2874        output: &MetalBatchOutputBuffer,
2875        session: &MetalBackendSession,
2876    ) -> Result<Vec<Result<Surface, Error>>, Error> {
2877        Codec::decode_rgb8_batch_into_buffer_with_session(
2878            Rgb8MetalBatchRequest {
2879                source: Rgb8MetalBatchSource::Bytes(inputs),
2880                op: Rgb8MetalBatchOp::Full,
2881            },
2882            MetalBufferBatchTarget::Reusable(output),
2883            session,
2884        )
2885    }
2886
2887    #[cfg(target_os = "macos")]
2888    fn decode_rgb8_batch_into_metal_textures_with_session(
2889        inputs: &[&[u8]],
2890        output: &MetalBatchTextureOutput,
2891        session: &MetalBackendSession,
2892    ) -> Result<Vec<Result<MetalTextureTile, Error>>, Error> {
2893        Codec::decode_rgb8_batch_into_textures_with_session(
2894            Rgb8MetalBatchRequest {
2895                source: Rgb8MetalBatchSource::Bytes(inputs),
2896                op: Rgb8MetalBatchOp::Full,
2897            },
2898            MetalTextureBatchTarget::Reusable(output),
2899            session,
2900        )
2901    }
2902
2903    #[cfg(target_os = "macos")]
2904    fn decode_rgb8_decoder_batch_into_metal_buffer_with_session(
2905        decoders: &[&Decoder<'_>],
2906        output: &MetalBatchOutputBuffer,
2907        session: &MetalBackendSession,
2908    ) -> Result<Vec<Result<Surface, Error>>, Error> {
2909        Codec::decode_rgb8_batch_into_buffer_with_session(
2910            Rgb8MetalBatchRequest {
2911                source: Rgb8MetalBatchSource::Decoders(decoders),
2912                op: Rgb8MetalBatchOp::Full,
2913            },
2914            MetalBufferBatchTarget::Reusable(output),
2915            session,
2916        )
2917    }
2918
2919    #[cfg(target_os = "macos")]
2920    fn decode_rgb8_decoder_batch_into_metal_textures_with_session(
2921        decoders: &[&Decoder<'_>],
2922        output: &MetalBatchTextureOutput,
2923        session: &MetalBackendSession,
2924    ) -> Result<Vec<Result<MetalTextureTile, Error>>, Error> {
2925        Codec::decode_rgb8_batch_into_textures_with_session(
2926            Rgb8MetalBatchRequest {
2927                source: Rgb8MetalBatchSource::Decoders(decoders),
2928                op: Rgb8MetalBatchOp::Full,
2929            },
2930            MetalTextureBatchTarget::Reusable(output),
2931            session,
2932        )
2933    }
2934
2935    #[cfg(target_os = "macos")]
2936    fn decode_rgb8_decoder_batch_into_resizable_metal_buffer_with_session(
2937        decoders: &[&Decoder<'_>],
2938        output: &mut MetalBatchOutputBuffer,
2939        session: &MetalBackendSession,
2940    ) -> Result<Vec<Result<Surface, Error>>, Error> {
2941        Codec::decode_rgb8_batch_into_buffer_with_session(
2942            Rgb8MetalBatchRequest {
2943                source: Rgb8MetalBatchSource::Decoders(decoders),
2944                op: Rgb8MetalBatchOp::Full,
2945            },
2946            MetalBufferBatchTarget::Resizable(output),
2947            session,
2948        )
2949    }
2950
2951    #[cfg(target_os = "macos")]
2952    fn decode_rgb8_scaled_batch_into_metal_buffer_with_session(
2953        inputs: &[&[u8]],
2954        scale: Downscale,
2955        output: &MetalBatchOutputBuffer,
2956        session: &MetalBackendSession,
2957    ) -> Result<Vec<Result<Surface, Error>>, Error> {
2958        Codec::decode_rgb8_batch_into_buffer_with_session(
2959            Rgb8MetalBatchRequest {
2960                source: Rgb8MetalBatchSource::Bytes(inputs),
2961                op: Rgb8MetalBatchOp::Scaled(scale),
2962            },
2963            MetalBufferBatchTarget::Reusable(output),
2964            session,
2965        )
2966    }
2967
2968    #[cfg(target_os = "macos")]
2969    fn decode_rgb8_scaled_batch_into_metal_textures_with_session(
2970        inputs: &[&[u8]],
2971        scale: Downscale,
2972        output: &MetalBatchTextureOutput,
2973        session: &MetalBackendSession,
2974    ) -> Result<Vec<Result<MetalTextureTile, Error>>, Error> {
2975        Codec::decode_rgb8_batch_into_textures_with_session(
2976            Rgb8MetalBatchRequest {
2977                source: Rgb8MetalBatchSource::Bytes(inputs),
2978                op: Rgb8MetalBatchOp::Scaled(scale),
2979            },
2980            MetalTextureBatchTarget::Reusable(output),
2981            session,
2982        )
2983    }
2984
2985    #[cfg(target_os = "macos")]
2986    fn decode_rgb8_scaled_batch_into_resizable_metal_textures_with_session(
2987        inputs: &[&[u8]],
2988        scale: Downscale,
2989        output: &mut MetalBatchTextureOutput,
2990        session: &MetalBackendSession,
2991    ) -> Result<Vec<Result<MetalTextureTile, Error>>, Error> {
2992        Codec::decode_rgb8_batch_into_textures_with_session(
2993            Rgb8MetalBatchRequest {
2994                source: Rgb8MetalBatchSource::Bytes(inputs),
2995                op: Rgb8MetalBatchOp::Scaled(scale),
2996            },
2997            MetalTextureBatchTarget::Resizable(output),
2998            session,
2999        )
3000    }
3001
3002    #[cfg(target_os = "macos")]
3003    fn decode_rgb8_decoder_scaled_batch_into_metal_buffer_with_session(
3004        decoders: &[&Decoder<'_>],
3005        scale: Downscale,
3006        output: &MetalBatchOutputBuffer,
3007        session: &MetalBackendSession,
3008    ) -> Result<Vec<Result<Surface, Error>>, Error> {
3009        Codec::decode_rgb8_batch_into_buffer_with_session(
3010            Rgb8MetalBatchRequest {
3011                source: Rgb8MetalBatchSource::Decoders(decoders),
3012                op: Rgb8MetalBatchOp::Scaled(scale),
3013            },
3014            MetalBufferBatchTarget::Reusable(output),
3015            session,
3016        )
3017    }
3018
3019    #[cfg(target_os = "macos")]
3020    fn decode_rgb8_decoder_scaled_batch_into_metal_textures_with_session(
3021        decoders: &[&Decoder<'_>],
3022        scale: Downscale,
3023        output: &MetalBatchTextureOutput,
3024        session: &MetalBackendSession,
3025    ) -> Result<Vec<Result<MetalTextureTile, Error>>, Error> {
3026        Codec::decode_rgb8_batch_into_textures_with_session(
3027            Rgb8MetalBatchRequest {
3028                source: Rgb8MetalBatchSource::Decoders(decoders),
3029                op: Rgb8MetalBatchOp::Scaled(scale),
3030            },
3031            MetalTextureBatchTarget::Reusable(output),
3032            session,
3033        )
3034    }
3035
3036    #[cfg(target_os = "macos")]
3037    fn decode_rgb8_decoder_scaled_batch_into_resizable_metal_buffer_with_session(
3038        decoders: &[&Decoder<'_>],
3039        scale: Downscale,
3040        output: &mut MetalBatchOutputBuffer,
3041        session: &MetalBackendSession,
3042    ) -> Result<Vec<Result<Surface, Error>>, Error> {
3043        Codec::decode_rgb8_batch_into_buffer_with_session(
3044            Rgb8MetalBatchRequest {
3045                source: Rgb8MetalBatchSource::Decoders(decoders),
3046                op: Rgb8MetalBatchOp::Scaled(scale),
3047            },
3048            MetalBufferBatchTarget::Resizable(output),
3049            session,
3050        )
3051    }
3052
3053    #[cfg(target_os = "macos")]
3054    fn decode_rgb8_decoder_scaled_batch_into_resizable_metal_textures_with_session(
3055        decoders: &[&Decoder<'_>],
3056        scale: Downscale,
3057        output: &mut MetalBatchTextureOutput,
3058        session: &MetalBackendSession,
3059    ) -> Result<Vec<Result<MetalTextureTile, Error>>, Error> {
3060        Codec::decode_rgb8_batch_into_textures_with_session(
3061            Rgb8MetalBatchRequest {
3062                source: Rgb8MetalBatchSource::Decoders(decoders),
3063                op: Rgb8MetalBatchOp::Scaled(scale),
3064            },
3065            MetalTextureBatchTarget::Resizable(output),
3066            session,
3067        )
3068    }
3069
3070    #[cfg(target_os = "macos")]
3071    fn decode_rgb8_region_scaled_batch_into_metal_buffer_with_session(
3072        inputs: &[&[u8]],
3073        roi: Rect,
3074        scale: Downscale,
3075        output: &MetalBatchOutputBuffer,
3076        session: &MetalBackendSession,
3077    ) -> Result<Vec<Result<Surface, Error>>, Error> {
3078        Codec::decode_rgb8_batch_into_buffer_with_session(
3079            Rgb8MetalBatchRequest {
3080                source: Rgb8MetalBatchSource::Bytes(inputs),
3081                op: Rgb8MetalBatchOp::RegionScaled { roi, scale },
3082            },
3083            MetalBufferBatchTarget::Reusable(output),
3084            session,
3085        )
3086    }
3087
3088    #[cfg(target_os = "macos")]
3089    fn decode_rgb8_region_scaled_batch_into_metal_textures_with_session(
3090        inputs: &[&[u8]],
3091        roi: Rect,
3092        scale: Downscale,
3093        output: &MetalBatchTextureOutput,
3094        session: &MetalBackendSession,
3095    ) -> Result<Vec<Result<MetalTextureTile, Error>>, Error> {
3096        Codec::decode_rgb8_batch_into_textures_with_session(
3097            Rgb8MetalBatchRequest {
3098                source: Rgb8MetalBatchSource::Bytes(inputs),
3099                op: Rgb8MetalBatchOp::RegionScaled { roi, scale },
3100            },
3101            MetalTextureBatchTarget::Reusable(output),
3102            session,
3103        )
3104    }
3105
3106    #[cfg(target_os = "macos")]
3107    fn decode_rgb8_decoder_region_scaled_batch_into_metal_buffer_with_session(
3108        decoders: &[&Decoder<'_>],
3109        roi: Rect,
3110        scale: Downscale,
3111        output: &MetalBatchOutputBuffer,
3112        session: &MetalBackendSession,
3113    ) -> Result<Vec<Result<Surface, Error>>, Error> {
3114        Codec::decode_rgb8_batch_into_buffer_with_session(
3115            Rgb8MetalBatchRequest {
3116                source: Rgb8MetalBatchSource::Decoders(decoders),
3117                op: Rgb8MetalBatchOp::RegionScaled { roi, scale },
3118            },
3119            MetalBufferBatchTarget::Reusable(output),
3120            session,
3121        )
3122    }
3123
3124    #[cfg(target_os = "macos")]
3125    fn decode_rgb8_decoder_region_scaled_batch_into_metal_textures_with_session(
3126        decoders: &[&Decoder<'_>],
3127        roi: Rect,
3128        scale: Downscale,
3129        output: &MetalBatchTextureOutput,
3130        session: &MetalBackendSession,
3131    ) -> Result<Vec<Result<MetalTextureTile, Error>>, Error> {
3132        Codec::decode_rgb8_batch_into_textures_with_session(
3133            Rgb8MetalBatchRequest {
3134                source: Rgb8MetalBatchSource::Decoders(decoders),
3135                op: Rgb8MetalBatchOp::RegionScaled { roi, scale },
3136            },
3137            MetalTextureBatchTarget::Reusable(output),
3138            session,
3139        )
3140    }
3141
3142    #[cfg(target_os = "macos")]
3143    fn decode_rgb8_decoder_region_scaled_batch_into_resizable_metal_buffer_with_session(
3144        decoders: &[&Decoder<'_>],
3145        roi: Rect,
3146        scale: Downscale,
3147        output: &mut MetalBatchOutputBuffer,
3148        session: &MetalBackendSession,
3149    ) -> Result<Vec<Result<Surface, Error>>, Error> {
3150        Codec::decode_rgb8_batch_into_buffer_with_session(
3151            Rgb8MetalBatchRequest {
3152                source: Rgb8MetalBatchSource::Decoders(decoders),
3153                op: Rgb8MetalBatchOp::RegionScaled { roi, scale },
3154            },
3155            MetalBufferBatchTarget::Resizable(output),
3156            session,
3157        )
3158    }
3159
3160    #[cfg(target_os = "macos")]
3161    fn decode_rgb8_decoder_region_scaled_batch_into_resizable_metal_textures_with_session(
3162        decoders: &[&Decoder<'_>],
3163        roi: Rect,
3164        scale: Downscale,
3165        output: &mut MetalBatchTextureOutput,
3166        session: &MetalBackendSession,
3167    ) -> Result<Vec<Result<MetalTextureTile, Error>>, Error> {
3168        Codec::decode_rgb8_batch_into_textures_with_session(
3169            Rgb8MetalBatchRequest {
3170                source: Rgb8MetalBatchSource::Decoders(decoders),
3171                op: Rgb8MetalBatchOp::RegionScaled { roi, scale },
3172            },
3173            MetalTextureBatchTarget::Resizable(output),
3174            session,
3175        )
3176    }
3177
3178    #[cfg(target_os = "macos")]
3179    use j2k_jpeg::adapter::build_fast422_packet;
3180    use j2k_jpeg::adapter::{build_fast420_packet, build_fast444_packet};
3181    #[cfg(target_os = "macos")]
3182    use j2k_jpeg::{
3183        encode_jpeg_baseline, JpegBackend, JpegEncodeOptions, JpegSamples, JpegSubsampling,
3184    };
3185
3186    const BASELINE_420: &[u8] = include_bytes!("../fixtures/jpeg/baseline_420_16x16.jpg");
3187    const BASELINE_420_RESTART: &[u8] =
3188        include_bytes!("../fixtures/jpeg/baseline_420_restart_32x16.jpg");
3189    #[cfg(target_os = "macos")]
3190    const BASELINE_422: &[u8] = include_bytes!("../fixtures/jpeg/baseline_422_16x8.jpg");
3191    const BASELINE_444: &[u8] = include_bytes!("../fixtures/jpeg/baseline_444_8x8.jpg");
3192    #[cfg(not(target_os = "macos"))]
3193    const GRAYSCALE: &[u8] = include_bytes!("../fixtures/jpeg/grayscale_8x8.jpg");
3194
3195    #[test]
3196    fn metal_runtime_failures_are_not_unsupported_errors() {
3197        for err in [
3198            Error::MetalRuntime {
3199                message: "runtime".to_string(),
3200            },
3201            Error::MetalKernel {
3202                message: "kernel".to_string(),
3203            },
3204            Error::MetalStatePoisoned {
3205                state: "JPEG Metal session",
3206            },
3207        ] {
3208            assert!(!err.is_unsupported(), "{err:?}");
3209        }
3210    }
3211
3212    #[test]
3213    fn auto_route_prefers_cpu_host_for_nonrestart_packets() {
3214        let decoder_420 = CpuDecoder::new(BASELINE_420).expect("420 decoder");
3215        let packet_420 = build_fast420_packet(BASELINE_420).expect("420 packet");
3216        assert_eq!(
3217            choose_route(
3218                &decoder_420,
3219                BackendRequest::Auto,
3220                PixelFormat::Rgb8,
3221                batch::BatchOp::Full,
3222                None,
3223                None,
3224                Some(&packet_420),
3225            ),
3226            routing::RouteDecision::CpuHost
3227        );
3228
3229        let decoder_444 = CpuDecoder::new(BASELINE_444).expect("444 decoder");
3230        let packet_444 = build_fast444_packet(BASELINE_444).expect("444 packet");
3231        assert_eq!(
3232            choose_route(
3233                &decoder_444,
3234                BackendRequest::Auto,
3235                PixelFormat::Rgb8,
3236                batch::BatchOp::Scaled(Downscale::Quarter),
3237                Some(&packet_444),
3238                None,
3239                None,
3240            ),
3241            routing::RouteDecision::CpuHost
3242        );
3243    }
3244
3245    #[test]
3246    fn auto_route_keeps_small_single_restart_packets_on_cpu_host() {
3247        let decoder = CpuDecoder::new(BASELINE_420_RESTART).expect("restart decoder");
3248        let packet = build_fast420_packet(BASELINE_420_RESTART).expect("restart packet");
3249
3250        assert_eq!(
3251            choose_route(
3252                &decoder,
3253                BackendRequest::Auto,
3254                PixelFormat::Rgb8,
3255                batch::BatchOp::Full,
3256                None,
3257                None,
3258                Some(&packet)
3259            ),
3260            routing::RouteDecision::CpuHost
3261        );
3262        assert_eq!(
3263            choose_route(
3264                &decoder,
3265                BackendRequest::Auto,
3266                PixelFormat::Rgb8,
3267                batch::BatchOp::Region(Rect {
3268                    x: 0,
3269                    y: 0,
3270                    w: 16,
3271                    h: 16,
3272                }),
3273                None,
3274                None,
3275                Some(&packet),
3276            ),
3277            routing::RouteDecision::CpuHost
3278        );
3279    }
3280
3281    #[cfg(target_os = "macos")]
3282    #[test]
3283    fn metal_backend_session_reuses_compiled_runtime() {
3284        let session = MetalBackendSession::system_default().expect("Metal backend session");
3285        assert!(session.runtime.get().is_none());
3286
3287        let mut first = Decoder::new(BASELINE_420).expect("first decoder");
3288        let first_surface = first
3289            .decode_to_device_with_session(PixelFormat::Rgb8, &session)
3290            .expect("first session decode");
3291        assert_eq!(
3292            first_surface.residency(),
3293            SurfaceResidency::MetalResidentDecode
3294        );
3295        let first_runtime = session
3296            .runtime
3297            .get()
3298            .and_then(|runtime| runtime.as_ref().ok())
3299            .map(std::ptr::from_ref::<compute::MetalRuntime>)
3300            .expect("session runtime after first decode");
3301
3302        let mut second = Decoder::new(BASELINE_420).expect("second decoder");
3303        second
3304            .decode_to_device_with_session(PixelFormat::Rgb8, &session)
3305            .expect("second session decode");
3306        let second_runtime = session
3307            .runtime
3308            .get()
3309            .and_then(|runtime| runtime.as_ref().ok())
3310            .map(std::ptr::from_ref::<compute::MetalRuntime>)
3311            .expect("session runtime after second decode");
3312
3313        assert_eq!(first_runtime, second_runtime);
3314    }
3315
3316    #[cfg(target_os = "macos")]
3317    #[test]
3318    fn jpeg_rgb8_batch_decode_uses_backend_session_runtime() {
3319        let session = MetalBackendSession::system_default().expect("Metal backend session");
3320        assert!(session.runtime.get().is_none());
3321
3322        let inputs = [BASELINE_420, BASELINE_420];
3323        let results = decode_rgb8_batch_to_device_with_session(&inputs, &session)
3324            .expect("session batch decode")
3325            .expect("baseline JPEG batch should use Metal batch path");
3326
3327        assert_eq!(results.len(), 2);
3328        assert!(session.runtime.get().is_some());
3329        for result in results {
3330            let surface = result.expect("surface");
3331            assert_eq!(surface.backend_kind(), BackendKind::Metal);
3332            assert_eq!(surface.residency(), SurfaceResidency::MetalResidentDecode);
3333            assert_eq!(surface.dimensions(), (16, 16));
3334            assert_eq!(surface.pixel_format(), PixelFormat::Rgb8);
3335        }
3336    }
3337
3338    #[cfg(target_os = "macos")]
3339    #[test]
3340    fn queued_jpeg_batch_decode_uses_metal_session_runtime() {
3341        use j2k_core::DeviceSubmission as _;
3342
3343        let backend_session = MetalBackendSession::system_default().expect("Metal backend session");
3344        assert!(backend_session.runtime.get().is_none());
3345        let mut session = MetalSession::with_backend_session(backend_session.clone());
3346        let mut ctx = j2k_core::DecoderContext::<j2k_jpeg::DecoderContext>::new();
3347        let mut pool = ScratchPool::new();
3348
3349        let submissions = (0..2)
3350            .map(|_| {
3351                <Codec as j2k_core::TileBatchDecodeSubmit>::submit_tile_to_device(
3352                    &mut ctx,
3353                    &mut session,
3354                    &mut pool,
3355                    BASELINE_420,
3356                    PixelFormat::Rgb8,
3357                    BackendRequest::Metal,
3358                )
3359                .expect("queued Metal tile submit")
3360            })
3361            .collect::<Vec<_>>();
3362
3363        for submission in submissions {
3364            let surface = submission.wait().expect("queued Metal surface");
3365            assert_eq!(surface.backend_kind(), BackendKind::Metal);
3366            assert_eq!(surface.residency(), SurfaceResidency::MetalResidentDecode);
3367            assert_eq!(surface.dimensions(), (16, 16));
3368        }
3369
3370        assert_eq!(session.submissions().expect("session submissions"), 1);
3371        assert!(
3372            backend_session.runtime.get().is_some(),
3373            "queued MetalSession batch decode should reuse its backend runtime"
3374        );
3375    }
3376
3377    #[cfg(target_os = "macos")]
3378    #[test]
3379    fn default_queued_jpeg_batch_decode_lazily_initializes_backend_session() {
3380        use j2k_core::DeviceSubmission as _;
3381
3382        let mut session = MetalSession::default();
3383        assert!(session
3384            .shared
3385            .0
3386            .lock()
3387            .expect("metal session")
3388            .backend_session
3389            .is_none());
3390        let mut ctx = j2k_core::DecoderContext::<j2k_jpeg::DecoderContext>::new();
3391        let mut pool = ScratchPool::new();
3392
3393        let submissions = (0..2)
3394            .map(|_| {
3395                <Codec as j2k_core::TileBatchDecodeSubmit>::submit_tile_to_device(
3396                    &mut ctx,
3397                    &mut session,
3398                    &mut pool,
3399                    BASELINE_420,
3400                    PixelFormat::Rgb8,
3401                    BackendRequest::Metal,
3402                )
3403                .expect("queued Metal tile submit")
3404            })
3405            .collect::<Vec<_>>();
3406
3407        for submission in submissions {
3408            let surface = submission.wait().expect("queued Metal surface");
3409            assert_eq!(surface.residency(), SurfaceResidency::MetalResidentDecode);
3410        }
3411
3412        let runtime_initialized = session
3413            .shared
3414            .0
3415            .lock()
3416            .expect("metal session")
3417            .backend_session
3418            .as_ref()
3419            .and_then(|backend| backend.runtime.get())
3420            .is_some();
3421        assert!(runtime_initialized);
3422    }
3423
3424    #[cfg(target_os = "macos")]
3425    #[test]
3426    fn rgb8_batch_decode_can_write_into_reusable_metal_output_buffer() {
3427        let session = MetalBackendSession::system_default().expect("Metal backend session");
3428        let output =
3429            MetalBatchOutputBuffer::new_rgb8_tiles(&session, (16, 16), 2).expect("output buffer");
3430        let inputs = [BASELINE_420, BASELINE_420];
3431        let (expected, _) = CpuDecoder::new(BASELINE_420)
3432            .expect("cpu decoder")
3433            .decode(PixelFormat::Rgb8)
3434            .expect("cpu decode");
3435
3436        let surfaces = decode_rgb8_batch_into_metal_buffer_with_session(&inputs, &output, &session)
3437            .expect("decode into reusable output");
3438
3439        assert_eq!(surfaces.len(), 2);
3440        assert_eq!(output.tile_capacity(), 2);
3441        assert_eq!(
3442            output.tile_stride_bytes(),
3443            16 * 16 * PixelFormat::Rgb8.bytes_per_pixel()
3444        );
3445        for (index, result) in surfaces.into_iter().enumerate() {
3446            let surface = result.expect("surface");
3447            assert_eq!(surface.residency(), SurfaceResidency::MetalResidentDecode);
3448            assert_eq!(surface.dimensions(), (16, 16));
3449            assert_eq!(surface.pixel_format(), PixelFormat::Rgb8);
3450            let (buffer, offset) = surface.metal_buffer().expect("metal buffer");
3451            assert!(std::ptr::eq(buffer.as_ref(), output.buffer()));
3452            assert_eq!(offset, index * output.tile_stride_bytes());
3453            assert_eq!(surface.as_bytes(), expected.as_slice());
3454        }
3455    }
3456
3457    #[cfg(target_os = "macos")]
3458    #[test]
3459    fn rgb8_decoder_batch_resizes_reusable_metal_output_buffer() {
3460        let session = MetalBackendSession::system_default().expect("Metal backend session");
3461        let mut output =
3462            MetalBatchOutputBuffer::new_rgb8_tiles(&session, (1, 1), 1).expect("output buffer");
3463        let first = Decoder::new(BASELINE_420).expect("first decoder");
3464        let second = Decoder::new(BASELINE_420).expect("second decoder");
3465        let decoders = [&first, &second];
3466        let (expected, _) = CpuDecoder::new(BASELINE_420)
3467            .expect("cpu decoder")
3468            .decode(PixelFormat::Rgb8)
3469            .expect("cpu decode");
3470
3471        let surfaces = decode_rgb8_decoder_batch_into_resizable_metal_buffer_with_session(
3472            &decoders,
3473            &mut output,
3474            &session,
3475        )
3476        .expect("decode cached decoder batch into resizable reusable output");
3477
3478        assert_eq!(output.dimensions(), (16, 16));
3479        assert_eq!(output.tile_capacity(), 2);
3480        assert_eq!(surfaces.len(), 2);
3481        for (index, result) in surfaces.into_iter().enumerate() {
3482            let surface = result.expect("surface");
3483            assert_eq!(surface.residency(), SurfaceResidency::MetalResidentDecode);
3484            assert_eq!(surface.dimensions(), (16, 16));
3485            assert_eq!(surface.pixel_format(), PixelFormat::Rgb8);
3486            let (buffer, offset) = surface.metal_buffer().expect("metal buffer");
3487            assert!(std::ptr::eq(buffer.as_ref(), output.buffer()));
3488            assert_eq!(offset, index * output.tile_stride_bytes());
3489            assert_eq!(surface.as_bytes(), expected.as_slice());
3490        }
3491    }
3492
3493    #[cfg(target_os = "macos")]
3494    #[test]
3495    fn rgb8_decoder_batch_can_write_into_fixed_metal_output_buffer() {
3496        let session = MetalBackendSession::system_default().expect("Metal backend session");
3497        let output =
3498            MetalBatchOutputBuffer::new_rgb8_tiles(&session, (16, 16), 2).expect("output buffer");
3499        let first = Decoder::new(BASELINE_420).expect("first decoder");
3500        let second = Decoder::new(BASELINE_420).expect("second decoder");
3501        let decoders = [&first, &second];
3502        let (expected, _) = CpuDecoder::new(BASELINE_420)
3503            .expect("cpu decoder")
3504            .decode(PixelFormat::Rgb8)
3505            .expect("cpu decode");
3506
3507        let surfaces =
3508            decode_rgb8_decoder_batch_into_metal_buffer_with_session(&decoders, &output, &session)
3509                .expect("decode cached decoder batch into fixed reusable output");
3510
3511        assert_eq!(surfaces.len(), 2);
3512        assert_eq!(output.dimensions(), (16, 16));
3513        assert_eq!(output.tile_capacity(), 2);
3514        for (index, result) in surfaces.into_iter().enumerate() {
3515            let surface = result.expect("surface");
3516            assert_eq!(surface.residency(), SurfaceResidency::MetalResidentDecode);
3517            assert_eq!(surface.dimensions(), (16, 16));
3518            assert_eq!(surface.pixel_format(), PixelFormat::Rgb8);
3519            let (buffer, offset) = surface.metal_buffer().expect("metal buffer");
3520            assert!(std::ptr::eq(buffer.as_ref(), output.buffer()));
3521            assert_eq!(offset, index * output.tile_stride_bytes());
3522            assert_eq!(surface.as_bytes(), expected.as_slice());
3523        }
3524    }
3525
3526    #[cfg(target_os = "macos")]
3527    #[test]
3528    fn rgb8_decoder_batch_rejects_mixed_output_dimensions_without_resizing_buffer() {
3529        let session = MetalBackendSession::system_default().expect("Metal backend session");
3530        let mut output =
3531            MetalBatchOutputBuffer::new_rgb8_tiles(&session, (1, 1), 1).expect("output buffer");
3532        let first = Decoder::new(BASELINE_420).expect("first decoder");
3533        let second = Decoder::new(BASELINE_444).expect("second decoder");
3534        let decoders = [&first, &second];
3535
3536        let Err(err) = decode_rgb8_decoder_batch_into_resizable_metal_buffer_with_session(
3537            &decoders,
3538            &mut output,
3539            &session,
3540        ) else {
3541            panic!("mixed output dimensions should be rejected");
3542        };
3543
3544        assert!(matches!(err, Error::UnsupportedMetalRequest { .. }));
3545        assert_eq!(output.dimensions(), (1, 1));
3546        assert_eq!(output.tile_capacity(), 1);
3547    }
3548
3549    #[cfg(target_os = "macos")]
3550    #[test]
3551    fn rgb8_decoder_batch_rejects_mixed_sampling_without_resizing_buffer() {
3552        let session = MetalBackendSession::system_default().expect("Metal backend session");
3553        let mut output =
3554            MetalBatchOutputBuffer::new_rgb8_tiles(&session, (1, 1), 1).expect("output buffer");
3555        let rgb = j2k_test_support::patterned_rgb8(16, 16);
3556        let fast420 = encode_jpeg_baseline(
3557            JpegSamples::Rgb8 {
3558                data: &rgb,
3559                width: 16,
3560                height: 16,
3561            },
3562            JpegEncodeOptions {
3563                quality: 90,
3564                subsampling: JpegSubsampling::Ybr420,
3565                restart_interval: None,
3566                backend: JpegBackend::Cpu,
3567            },
3568        )
3569        .expect("encode fast420 jpeg");
3570        let fast444 = encode_jpeg_baseline(
3571            JpegSamples::Rgb8 {
3572                data: &rgb,
3573                width: 16,
3574                height: 16,
3575            },
3576            JpegEncodeOptions {
3577                quality: 90,
3578                subsampling: JpegSubsampling::Ybr444,
3579                restart_interval: None,
3580                backend: JpegBackend::Cpu,
3581            },
3582        )
3583        .expect("encode fast444 jpeg");
3584        let first = Decoder::new(&fast420.data).expect("first decoder");
3585        let second = Decoder::new(&fast444.data).expect("second decoder");
3586        let decoders = [&first, &second];
3587
3588        let Err(err) = decode_rgb8_decoder_batch_into_resizable_metal_buffer_with_session(
3589            &decoders,
3590            &mut output,
3591            &session,
3592        ) else {
3593            panic!("mixed sampling should be rejected");
3594        };
3595
3596        assert!(matches!(
3597            err,
3598            Error::UnsupportedMetalRequest { reason }
3599                if reason.contains("same fast-packet sampling family")
3600        ));
3601        assert_eq!(output.dimensions(), (1, 1));
3602        assert_eq!(output.tile_capacity(), 1);
3603    }
3604
3605    #[cfg(target_os = "macos")]
3606    #[test]
3607    fn rgb8_decoder_batch_metal_report_exposes_required_output_shape() {
3608        let first = Decoder::new(BASELINE_420).expect("first decoder");
3609        let second = Decoder::new(BASELINE_420).expect("second decoder");
3610        let decoders = [&first, &second];
3611
3612        let full =
3613            Codec::inspect_rgb8_decoder_batch_metal_output(&decoders, j2k_jpeg::JpegDecodeOp::Full);
3614        let scaled = Codec::inspect_rgb8_decoder_batch_metal_output(
3615            &decoders,
3616            j2k_jpeg::JpegDecodeOp::Scaled(Downscale::Quarter),
3617        );
3618        let roi = Rect {
3619            x: 1,
3620            y: 2,
3621            w: 10,
3622            h: 9,
3623        };
3624        let region_scaled = Codec::inspect_rgb8_decoder_batch_metal_output(
3625            &decoders,
3626            j2k_jpeg::JpegDecodeOp::RegionScaled {
3627                roi: j2k_jpeg::Rect {
3628                    x: roi.x,
3629                    y: roi.y,
3630                    w: roi.w,
3631                    h: roi.h,
3632                },
3633                scale: Downscale::Quarter,
3634            },
3635        );
3636
3637        assert!(full.eligibility.eligible);
3638        assert_eq!(full.tile_count, 2);
3639        assert_eq!(full.output_dimensions, Some((16, 16)));
3640        assert_eq!(full.required_tile_capacity(), 2);
3641
3642        assert!(scaled.eligibility.eligible);
3643        assert_eq!(scaled.output_dimensions, Some((4, 4)));
3644
3645        assert!(region_scaled.eligibility.eligible);
3646        let expected = roi.scaled_covering(Downscale::Quarter);
3647        assert_eq!(
3648            region_scaled.output_dimensions,
3649            Some((expected.w, expected.h))
3650        );
3651    }
3652
3653    #[cfg(target_os = "macos")]
3654    #[test]
3655    fn rgb8_decoder_batch_metal_report_rejects_incompatible_batches_without_launch() {
3656        let first = Decoder::new(BASELINE_420).expect("first decoder");
3657        let second = Decoder::new(BASELINE_444).expect("second decoder");
3658        let decoders = [&first, &second];
3659
3660        let mixed =
3661            Codec::inspect_rgb8_decoder_batch_metal_output(&decoders, j2k_jpeg::JpegDecodeOp::Full);
3662        let region = Codec::inspect_rgb8_decoder_batch_metal_output(
3663            &[&first],
3664            j2k_jpeg::JpegDecodeOp::Region(j2k_jpeg::Rect {
3665                x: 0,
3666                y: 0,
3667                w: 8,
3668                h: 8,
3669            }),
3670        );
3671
3672        assert!(!mixed.eligibility.eligible);
3673        assert_eq!(mixed.output_dimensions, None);
3674        assert!(mixed
3675            .eligibility
3676            .reason
3677            .expect("mixed rejection")
3678            .contains("matching output dimensions"));
3679
3680        assert!(!region.eligibility.eligible);
3681        assert!(region
3682            .eligibility
3683            .reason
3684            .expect("region rejection")
3685            .contains("full, scaled, or region-scaled"));
3686    }
3687
3688    #[cfg(target_os = "macos")]
3689    #[test]
3690    fn rgb8_decoder_batch_metal_report_rejects_mixed_sampling_family() {
3691        let rgb = j2k_test_support::patterned_rgb8(16, 16);
3692        let fast420 = encode_jpeg_baseline(
3693            JpegSamples::Rgb8 {
3694                data: &rgb,
3695                width: 16,
3696                height: 16,
3697            },
3698            JpegEncodeOptions {
3699                quality: 90,
3700                subsampling: JpegSubsampling::Ybr420,
3701                restart_interval: None,
3702                backend: JpegBackend::Cpu,
3703            },
3704        )
3705        .expect("encode fast420 jpeg");
3706        let fast444 = encode_jpeg_baseline(
3707            JpegSamples::Rgb8 {
3708                data: &rgb,
3709                width: 16,
3710                height: 16,
3711            },
3712            JpegEncodeOptions {
3713                quality: 90,
3714                subsampling: JpegSubsampling::Ybr444,
3715                restart_interval: None,
3716                backend: JpegBackend::Cpu,
3717            },
3718        )
3719        .expect("encode fast444 jpeg");
3720        let first = Decoder::new(&fast420.data).expect("first decoder");
3721        let second = Decoder::new(&fast444.data).expect("second decoder");
3722        let decoders = [&first, &second];
3723
3724        let report =
3725            Codec::inspect_rgb8_decoder_batch_metal_output(&decoders, j2k_jpeg::JpegDecodeOp::Full);
3726
3727        assert!(!report.eligibility.eligible);
3728        assert_eq!(report.output_dimensions, None);
3729        assert!(report
3730            .eligibility
3731            .reason
3732            .expect("mixed sampling rejection")
3733            .contains("same fast-packet sampling family"));
3734    }
3735
3736    #[cfg(target_os = "macos")]
3737    #[test]
3738    fn rgb8_decoder_batch_metal_report_rejects_restart_fast422_full_tiles() {
3739        let rgb = j2k_test_support::patterned_rgb8(64, 32);
3740        let jpeg = encode_jpeg_baseline(
3741            JpegSamples::Rgb8 {
3742                data: &rgb,
3743                width: 64,
3744                height: 32,
3745            },
3746            JpegEncodeOptions {
3747                quality: 90,
3748                subsampling: JpegSubsampling::Ybr422,
3749                restart_interval: Some(4),
3750                backend: JpegBackend::Cpu,
3751            },
3752        )
3753        .expect("encode restart fast422 jpeg");
3754        let packet = build_fast422_packet(&jpeg.data).expect("restart fast422 packet");
3755        assert_ne!(packet.restart_interval_mcus, 0);
3756        let first = Decoder::new(&jpeg.data).expect("first decoder");
3757        let second = Decoder::new(&jpeg.data).expect("second decoder");
3758        let decoders = [&first, &second];
3759
3760        let full =
3761            Codec::inspect_rgb8_decoder_batch_metal_output(&decoders, j2k_jpeg::JpegDecodeOp::Full);
3762        let scaled = Codec::inspect_rgb8_decoder_batch_metal_output(
3763            &decoders,
3764            j2k_jpeg::JpegDecodeOp::Scaled(Downscale::Half),
3765        );
3766
3767        assert!(!full.eligibility.eligible);
3768        assert_eq!(full.output_dimensions, None);
3769        assert!(full
3770            .eligibility
3771            .reason
3772            .expect("restart fast422 full rejection")
3773            .contains("restart-coded full-tile 4:2:2 or 4:4:4"));
3774
3775        assert!(scaled.eligibility.eligible);
3776        assert_eq!(scaled.output_dimensions, Some((32, 16)));
3777    }
3778
3779    #[cfg(target_os = "macos")]
3780    #[test]
3781    fn rgb8_fast444_batch_decode_can_write_into_reusable_metal_output_buffer() {
3782        let session = MetalBackendSession::system_default().expect("Metal backend session");
3783        let output =
3784            MetalBatchOutputBuffer::new_rgb8_tiles(&session, (8, 8), 2).expect("output buffer");
3785        let inputs = [BASELINE_444, BASELINE_444];
3786        let (expected, _) = CpuDecoder::new(BASELINE_444)
3787            .expect("cpu decoder")
3788            .decode(PixelFormat::Rgb8)
3789            .expect("cpu decode");
3790
3791        let surfaces = decode_rgb8_batch_into_metal_buffer_with_session(&inputs, &output, &session)
3792            .expect("decode into reusable output");
3793
3794        assert_eq!(surfaces.len(), 2);
3795        for (index, result) in surfaces.into_iter().enumerate() {
3796            let surface = result.expect("surface");
3797            assert_eq!(surface.residency(), SurfaceResidency::MetalResidentDecode);
3798            assert_eq!(surface.dimensions(), (8, 8));
3799            assert_eq!(surface.pixel_format(), PixelFormat::Rgb8);
3800            let (buffer, offset) = surface.metal_buffer().expect("metal buffer");
3801            assert!(std::ptr::eq(buffer.as_ref(), output.buffer()));
3802            assert_eq!(offset, index * output.tile_stride_bytes());
3803            assert_eq!(surface.as_bytes(), expected.as_slice());
3804        }
3805    }
3806
3807    #[cfg(target_os = "macos")]
3808    fn assert_table_mixed_full_buffer_groups_resident(
3809        subsampling: JpegSubsampling,
3810        dimensions: (u32, u32),
3811        first_quality: u8,
3812        second_quality: u8,
3813    ) {
3814        let session = MetalBackendSession::system_default().expect("Metal backend session");
3815        let rgb_a = j2k_test_support::patterned_rgb8(dimensions.0, dimensions.1);
3816        let mut rgb_b = j2k_test_support::patterned_rgb8(dimensions.0, dimensions.1);
3817        let mut rgb_c = j2k_test_support::patterned_rgb8(dimensions.0, dimensions.1);
3818        for (index, pixel) in rgb_b.chunks_exact_mut(3).enumerate() {
3819            let delta = patterned_index_byte(index)
3820                .wrapping_mul(43)
3821                .wrapping_add(17);
3822            pixel[0] ^= delta.rotate_left(1);
3823            pixel[1] = pixel[1].wrapping_sub(delta);
3824            pixel[2] = pixel[2].wrapping_add(delta.rotate_right(2));
3825        }
3826        for (index, pixel) in rgb_c.chunks_exact_mut(3).enumerate() {
3827            let delta = patterned_index_byte(index)
3828                .wrapping_mul(47)
3829                .wrapping_add(23);
3830            pixel[0] = pixel[0].wrapping_add(delta.rotate_left(2));
3831            pixel[1] ^= delta.rotate_right(1);
3832            pixel[2] = pixel[2].wrapping_sub(delta);
3833        }
3834
3835        let jpeg_a = encode_jpeg_baseline(
3836            JpegSamples::Rgb8 {
3837                data: &rgb_a,
3838                width: dimensions.0,
3839                height: dimensions.1,
3840            },
3841            JpegEncodeOptions {
3842                quality: first_quality,
3843                subsampling,
3844                restart_interval: None,
3845                backend: JpegBackend::Cpu,
3846            },
3847        )
3848        .expect("encode first table-mixed full buffer jpeg");
3849        let jpeg_b = encode_jpeg_baseline(
3850            JpegSamples::Rgb8 {
3851                data: &rgb_b,
3852                width: dimensions.0,
3853                height: dimensions.1,
3854            },
3855            JpegEncodeOptions {
3856                quality: second_quality,
3857                subsampling,
3858                restart_interval: None,
3859                backend: JpegBackend::Cpu,
3860            },
3861        )
3862        .expect("encode second table-mixed full buffer jpeg");
3863        let jpeg_c = encode_jpeg_baseline(
3864            JpegSamples::Rgb8 {
3865                data: &rgb_c,
3866                width: dimensions.0,
3867                height: dimensions.1,
3868            },
3869            JpegEncodeOptions {
3870                quality: first_quality,
3871                subsampling,
3872                restart_interval: None,
3873                backend: JpegBackend::Cpu,
3874            },
3875        )
3876        .expect("encode third table-mixed full buffer jpeg");
3877
3878        match subsampling {
3879            JpegSubsampling::Ybr420 => {
3880                let packet_a = build_fast420_packet(&jpeg_a.data).expect("first packet");
3881                let packet_b = build_fast420_packet(&jpeg_b.data).expect("second packet");
3882                let packet_c = build_fast420_packet(&jpeg_c.data).expect("third packet");
3883                assert_eq!(packet_a.y_quant, packet_c.y_quant);
3884                assert_eq!(packet_a.y_dc_table, packet_c.y_dc_table);
3885                assert_eq!(
3886                    packet_a.entropy_checkpoints.len(),
3887                    packet_c.entropy_checkpoints.len()
3888                );
3889                assert_ne!(packet_a.y_quant, packet_b.y_quant);
3890            }
3891            JpegSubsampling::Ybr422 => {
3892                let packet_a = build_fast422_packet(&jpeg_a.data).expect("first packet");
3893                let packet_b = build_fast422_packet(&jpeg_b.data).expect("second packet");
3894                let packet_c = build_fast422_packet(&jpeg_c.data).expect("third packet");
3895                assert_eq!(packet_a.y_quant, packet_c.y_quant);
3896                assert_eq!(packet_a.y_dc_table, packet_c.y_dc_table);
3897                assert_eq!(
3898                    packet_a.entropy_checkpoints.len(),
3899                    packet_c.entropy_checkpoints.len()
3900                );
3901                assert_ne!(packet_a.y_quant, packet_b.y_quant);
3902            }
3903            JpegSubsampling::Ybr444 => {
3904                let packet_a = build_fast444_packet(&jpeg_a.data).expect("first packet");
3905                let packet_b = build_fast444_packet(&jpeg_b.data).expect("second packet");
3906                let packet_c = build_fast444_packet(&jpeg_c.data).expect("third packet");
3907                assert_eq!(packet_a.y_quant, packet_c.y_quant);
3908                assert_eq!(packet_a.y_dc_table, packet_c.y_dc_table);
3909                assert_eq!(
3910                    packet_a.entropy_checkpoints.len(),
3911                    packet_c.entropy_checkpoints.len()
3912                );
3913                assert_ne!(packet_a.y_quant, packet_b.y_quant);
3914            }
3915            JpegSubsampling::Gray => panic!("table-mixed buffer helper expects YCbCr sampling"),
3916        }
3917
3918        let output =
3919            MetalBatchOutputBuffer::new_rgb8_tiles(&session, dimensions, 3).expect("output buffer");
3920        let inputs = [
3921            jpeg_a.data.as_slice(),
3922            jpeg_b.data.as_slice(),
3923            jpeg_c.data.as_slice(),
3924        ];
3925        let expected_tiles = inputs
3926            .iter()
3927            .map(|input| {
3928                CpuDecoder::new(input)
3929                    .expect("cpu decoder")
3930                    .decode(PixelFormat::Rgb8)
3931                    .expect("cpu decode")
3932                    .0
3933            })
3934            .collect::<Vec<_>>();
3935        assert_ne!(expected_tiles[0], expected_tiles[1]);
3936        assert_ne!(expected_tiles[0], expected_tiles[2]);
3937        assert_ne!(expected_tiles[1], expected_tiles[2]);
3938
3939        let surfaces = decode_rgb8_batch_into_metal_buffer_with_session(&inputs, &output, &session)
3940            .expect("decode table-mixed full tiles into reusable output buffer");
3941
3942        assert_eq!(surfaces.len(), 3);
3943        for (index, surface) in surfaces.into_iter().enumerate() {
3944            let surface = surface.expect("surface");
3945            assert_eq!(surface.residency(), SurfaceResidency::MetalResidentDecode);
3946            assert_eq!(surface.dimensions(), dimensions);
3947            assert_eq!(surface.pixel_format(), PixelFormat::Rgb8);
3948            let (buffer, offset) = surface.metal_buffer().expect("metal buffer");
3949            assert!(std::ptr::eq(buffer.as_ref(), output.buffer()));
3950            assert_eq!(offset, index * output.tile_stride_bytes());
3951            assert_eq!(surface.as_bytes(), expected_tiles[index].as_slice());
3952        }
3953    }
3954
3955    #[cfg(target_os = "macos")]
3956    #[test]
3957    fn rgb8_table_mixed_fast420_buffer_batch_groups_resident_dispatches() {
3958        assert_table_mixed_full_buffer_groups_resident(JpegSubsampling::Ybr420, (128, 96), 90, 72);
3959    }
3960
3961    #[cfg(target_os = "macos")]
3962    #[test]
3963    fn rgb8_table_mixed_fast422_buffer_batch_groups_resident_dispatches() {
3964        assert_table_mixed_full_buffer_groups_resident(JpegSubsampling::Ybr422, (128, 96), 91, 73);
3965    }
3966
3967    #[cfg(target_os = "macos")]
3968    #[test]
3969    fn rgb8_table_mixed_fast444_buffer_batch_groups_resident_dispatches() {
3970        assert_table_mixed_full_buffer_groups_resident(JpegSubsampling::Ybr444, (96, 96), 92, 74);
3971    }
3972
3973    #[cfg(target_os = "macos")]
3974    #[test]
3975    fn rgb8_scaled_batch_decode_can_write_into_reusable_metal_output_buffer() {
3976        let session = MetalBackendSession::system_default().expect("Metal backend session");
3977        let scale = Downscale::Quarter;
3978        let output =
3979            MetalBatchOutputBuffer::new_rgb8_tiles(&session, (4, 4), 2).expect("output buffer");
3980        let inputs = [BASELINE_420, BASELINE_420];
3981        let (expected, _) = CpuDecoder::new(BASELINE_420)
3982            .expect("cpu decoder")
3983            .decode_scaled(PixelFormat::Rgb8, scale)
3984            .expect("cpu scaled decode");
3985
3986        let surfaces = decode_rgb8_scaled_batch_into_metal_buffer_with_session(
3987            &inputs, scale, &output, &session,
3988        )
3989        .expect("decode scaled into reusable output");
3990
3991        assert_eq!(surfaces.len(), 2);
3992        for (index, result) in surfaces.into_iter().enumerate() {
3993            let surface = result.expect("surface");
3994            assert_eq!(surface.residency(), SurfaceResidency::MetalResidentDecode);
3995            assert_eq!(surface.dimensions(), (4, 4));
3996            assert_eq!(surface.pixel_format(), PixelFormat::Rgb8);
3997            let (buffer, offset) = surface.metal_buffer().expect("metal buffer");
3998            assert!(std::ptr::eq(buffer.as_ref(), output.buffer()));
3999            assert_eq!(offset, index * output.tile_stride_bytes());
4000            assert_eq!(surface.as_bytes(), expected.as_slice());
4001        }
4002    }
4003
4004    #[cfg(target_os = "macos")]
4005    #[test]
4006    fn rgb8_decoder_scaled_batch_resizes_reusable_metal_output_buffer() {
4007        let session = MetalBackendSession::system_default().expect("Metal backend session");
4008        let scale = Downscale::Quarter;
4009        let mut output =
4010            MetalBatchOutputBuffer::new_rgb8_tiles(&session, (1, 1), 1).expect("output buffer");
4011        let first = Decoder::new(BASELINE_420).expect("first decoder");
4012        let second = Decoder::new(BASELINE_420).expect("second decoder");
4013        let decoders = [&first, &second];
4014        let (expected, _) = CpuDecoder::new(BASELINE_420)
4015            .expect("cpu decoder")
4016            .decode_scaled(PixelFormat::Rgb8, scale)
4017            .expect("cpu scaled decode");
4018
4019        let surfaces = decode_rgb8_decoder_scaled_batch_into_resizable_metal_buffer_with_session(
4020            &decoders,
4021            scale,
4022            &mut output,
4023            &session,
4024        )
4025        .expect("decode cached decoder scaled batch into resizable reusable output");
4026
4027        assert_eq!(output.dimensions(), (4, 4));
4028        assert_eq!(output.tile_capacity(), 2);
4029        assert_eq!(surfaces.len(), 2);
4030        for (index, result) in surfaces.into_iter().enumerate() {
4031            let surface = result.expect("surface");
4032            assert_eq!(surface.residency(), SurfaceResidency::MetalResidentDecode);
4033            assert_eq!(surface.dimensions(), (4, 4));
4034            assert_eq!(surface.pixel_format(), PixelFormat::Rgb8);
4035            let (buffer, offset) = surface.metal_buffer().expect("metal buffer");
4036            assert!(std::ptr::eq(buffer.as_ref(), output.buffer()));
4037            assert_eq!(offset, index * output.tile_stride_bytes());
4038            assert_eq!(surface.as_bytes(), expected.as_slice());
4039        }
4040    }
4041
4042    #[cfg(target_os = "macos")]
4043    #[test]
4044    fn rgb8_decoder_scaled_batch_can_write_into_fixed_metal_output_buffer() {
4045        let session = MetalBackendSession::system_default().expect("Metal backend session");
4046        let scale = Downscale::Quarter;
4047        let output =
4048            MetalBatchOutputBuffer::new_rgb8_tiles(&session, (4, 4), 2).expect("output buffer");
4049        let first = Decoder::new(BASELINE_420).expect("first decoder");
4050        let second = Decoder::new(BASELINE_420).expect("second decoder");
4051        let decoders = [&first, &second];
4052        let (expected, _) = CpuDecoder::new(BASELINE_420)
4053            .expect("cpu decoder")
4054            .decode_scaled(PixelFormat::Rgb8, scale)
4055            .expect("cpu scaled decode");
4056
4057        let surfaces = decode_rgb8_decoder_scaled_batch_into_metal_buffer_with_session(
4058            &decoders, scale, &output, &session,
4059        )
4060        .expect("decode cached decoder scaled batch into fixed reusable output");
4061
4062        assert_eq!(surfaces.len(), 2);
4063        assert_eq!(output.dimensions(), (4, 4));
4064        assert_eq!(output.tile_capacity(), 2);
4065        for (index, result) in surfaces.into_iter().enumerate() {
4066            let surface = result.expect("surface");
4067            assert_eq!(surface.residency(), SurfaceResidency::MetalResidentDecode);
4068            assert_eq!(surface.dimensions(), (4, 4));
4069            assert_eq!(surface.pixel_format(), PixelFormat::Rgb8);
4070            let (buffer, offset) = surface.metal_buffer().expect("metal buffer");
4071            assert!(std::ptr::eq(buffer.as_ref(), output.buffer()));
4072            assert_eq!(offset, index * output.tile_stride_bytes());
4073            assert_eq!(surface.as_bytes(), expected.as_slice());
4074        }
4075    }
4076
4077    #[cfg(target_os = "macos")]
4078    #[test]
4079    fn rgb8_region_scaled_batch_decode_can_write_into_reusable_metal_output_buffer() {
4080        let session = MetalBackendSession::system_default().expect("Metal backend session");
4081        let roi = Rect {
4082            x: 1,
4083            y: 2,
4084            w: 5,
4085            h: 4,
4086        };
4087        let scale = Downscale::Quarter;
4088        let scaled = roi.scaled_covering(scale);
4089        let output = MetalBatchOutputBuffer::new_rgb8_tiles(&session, (scaled.w, scaled.h), 2)
4090            .expect("output buffer");
4091        let inputs = [BASELINE_444, BASELINE_444];
4092        let (expected, _) = CpuDecoder::new(BASELINE_444)
4093            .expect("cpu decoder")
4094            .decode_region_scaled(
4095                PixelFormat::Rgb8,
4096                j2k_jpeg::Rect {
4097                    x: roi.x,
4098                    y: roi.y,
4099                    w: roi.w,
4100                    h: roi.h,
4101                },
4102                scale,
4103            )
4104            .expect("cpu region scaled decode");
4105
4106        let surfaces = decode_rgb8_region_scaled_batch_into_metal_buffer_with_session(
4107            &inputs, roi, scale, &output, &session,
4108        )
4109        .expect("decode region scaled into reusable output");
4110
4111        assert_eq!(surfaces.len(), 2);
4112        for (index, result) in surfaces.into_iter().enumerate() {
4113            let surface = result.expect("surface");
4114            assert_eq!(surface.residency(), SurfaceResidency::MetalResidentDecode);
4115            assert_eq!(surface.dimensions(), (scaled.w, scaled.h));
4116            assert_eq!(surface.pixel_format(), PixelFormat::Rgb8);
4117            let (buffer, offset) = surface.metal_buffer().expect("metal buffer");
4118            assert!(std::ptr::eq(buffer.as_ref(), output.buffer()));
4119            assert_eq!(offset, index * output.tile_stride_bytes());
4120            assert_eq!(surface.as_bytes(), expected.as_slice());
4121        }
4122    }
4123
4124    #[cfg(target_os = "macos")]
4125    #[test]
4126    fn rgb8_region_scaled_batch_decode_resizes_reusable_metal_output_buffer() {
4127        let session = MetalBackendSession::system_default().expect("Metal backend session");
4128        let roi = Rect {
4129            x: 1,
4130            y: 2,
4131            w: 5,
4132            h: 4,
4133        };
4134        let scale = Downscale::Quarter;
4135        let scaled = roi.scaled_covering(scale);
4136        let mut output =
4137            MetalBatchOutputBuffer::new_rgb8_tiles(&session, (1, 1), 1).expect("output buffer");
4138        let inputs = [BASELINE_444, BASELINE_444];
4139        let (expected, _) = CpuDecoder::new(BASELINE_444)
4140            .expect("cpu decoder")
4141            .decode_region_scaled(
4142                PixelFormat::Rgb8,
4143                j2k_jpeg::Rect {
4144                    x: roi.x,
4145                    y: roi.y,
4146                    w: roi.w,
4147                    h: roi.h,
4148                },
4149                scale,
4150            )
4151            .expect("cpu region scaled decode");
4152
4153        let surfaces =
4154            Codec::decode_rgb8_region_scaled_batch_into_resizable_metal_buffer_with_session(
4155                &inputs,
4156                roi,
4157                scale,
4158                &mut output,
4159                &session,
4160            )
4161            .expect("decode region scaled into resizable reusable output");
4162
4163        assert_eq!(output.dimensions(), (scaled.w, scaled.h));
4164        assert_eq!(output.tile_capacity(), 2);
4165        assert_eq!(surfaces.len(), 2);
4166        for (index, result) in surfaces.into_iter().enumerate() {
4167            let surface = result.expect("surface");
4168            assert_eq!(surface.residency(), SurfaceResidency::MetalResidentDecode);
4169            assert_eq!(surface.dimensions(), (scaled.w, scaled.h));
4170            assert_eq!(surface.pixel_format(), PixelFormat::Rgb8);
4171            let (buffer, offset) = surface.metal_buffer().expect("metal buffer");
4172            assert!(std::ptr::eq(buffer.as_ref(), output.buffer()));
4173            assert_eq!(offset, index * output.tile_stride_bytes());
4174            assert_eq!(surface.as_bytes(), expected.as_slice());
4175        }
4176    }
4177
4178    #[cfg(target_os = "macos")]
4179    #[test]
4180    fn rgb8_decoder_region_scaled_batch_resizes_reusable_metal_output_buffer() {
4181        let session = MetalBackendSession::system_default().expect("Metal backend session");
4182        let roi = Rect {
4183            x: 1,
4184            y: 2,
4185            w: 10,
4186            h: 9,
4187        };
4188        let scale = Downscale::Quarter;
4189        let scaled = roi.scaled_covering(scale);
4190        let mut output =
4191            MetalBatchOutputBuffer::new_rgb8_tiles(&session, (1, 1), 1).expect("output buffer");
4192        let first = Decoder::new(BASELINE_420).expect("first decoder");
4193        let second = Decoder::new(BASELINE_420).expect("second decoder");
4194        let decoders = [&first, &second];
4195        let (expected, _) = CpuDecoder::new(BASELINE_420)
4196            .expect("cpu decoder")
4197            .decode_region_scaled(
4198                PixelFormat::Rgb8,
4199                j2k_jpeg::Rect {
4200                    x: roi.x,
4201                    y: roi.y,
4202                    w: roi.w,
4203                    h: roi.h,
4204                },
4205                scale,
4206            )
4207            .expect("cpu region scaled decode");
4208
4209        let surfaces =
4210            decode_rgb8_decoder_region_scaled_batch_into_resizable_metal_buffer_with_session(
4211                &decoders,
4212                roi,
4213                scale,
4214                &mut output,
4215                &session,
4216            )
4217            .expect("decode cached decoder batch into resizable reusable output");
4218
4219        assert_eq!(output.dimensions(), (scaled.w, scaled.h));
4220        assert_eq!(output.tile_capacity(), 2);
4221        assert_eq!(surfaces.len(), 2);
4222        for (index, result) in surfaces.into_iter().enumerate() {
4223            let surface = result.expect("surface");
4224            assert_eq!(surface.residency(), SurfaceResidency::MetalResidentDecode);
4225            assert_eq!(surface.dimensions(), (scaled.w, scaled.h));
4226            assert_eq!(surface.pixel_format(), PixelFormat::Rgb8);
4227            let (buffer, offset) = surface.metal_buffer().expect("metal buffer");
4228            assert!(std::ptr::eq(buffer.as_ref(), output.buffer()));
4229            assert_eq!(offset, index * output.tile_stride_bytes());
4230            assert_eq!(surface.as_bytes(), expected.as_slice());
4231        }
4232    }
4233
4234    #[cfg(target_os = "macos")]
4235    #[test]
4236    fn rgb8_decoder_region_scaled_batch_can_write_into_fixed_metal_output_buffer() {
4237        let session = MetalBackendSession::system_default().expect("Metal backend session");
4238        let roi = Rect {
4239            x: 1,
4240            y: 2,
4241            w: 10,
4242            h: 9,
4243        };
4244        let scale = Downscale::Quarter;
4245        let scaled = roi.scaled_covering(scale);
4246        let output = MetalBatchOutputBuffer::new_rgb8_tiles(&session, (scaled.w, scaled.h), 2)
4247            .expect("output buffer");
4248        let first = Decoder::new(BASELINE_420).expect("first decoder");
4249        let second = Decoder::new(BASELINE_420).expect("second decoder");
4250        let decoders = [&first, &second];
4251        let (expected, _) = CpuDecoder::new(BASELINE_420)
4252            .expect("cpu decoder")
4253            .decode_region_scaled(
4254                PixelFormat::Rgb8,
4255                j2k_jpeg::Rect {
4256                    x: roi.x,
4257                    y: roi.y,
4258                    w: roi.w,
4259                    h: roi.h,
4260                },
4261                scale,
4262            )
4263            .expect("cpu region scaled decode");
4264
4265        let surfaces = decode_rgb8_decoder_region_scaled_batch_into_metal_buffer_with_session(
4266            &decoders, roi, scale, &output, &session,
4267        )
4268        .expect("decode cached decoder region-scaled batch into fixed reusable output");
4269
4270        assert_eq!(surfaces.len(), 2);
4271        assert_eq!(output.dimensions(), (scaled.w, scaled.h));
4272        assert_eq!(output.tile_capacity(), 2);
4273        for (index, result) in surfaces.into_iter().enumerate() {
4274            let surface = result.expect("surface");
4275            assert_eq!(surface.residency(), SurfaceResidency::MetalResidentDecode);
4276            assert_eq!(surface.dimensions(), (scaled.w, scaled.h));
4277            assert_eq!(surface.pixel_format(), PixelFormat::Rgb8);
4278            let (buffer, offset) = surface.metal_buffer().expect("metal buffer");
4279            assert!(std::ptr::eq(buffer.as_ref(), output.buffer()));
4280            assert_eq!(offset, index * output.tile_stride_bytes());
4281            assert_eq!(surface.as_bytes(), expected.as_slice());
4282        }
4283    }
4284
4285    #[cfg(target_os = "macos")]
4286    #[test]
4287    fn rgb8_restart_fast420_region_scaled_batch_decode_writes_reusable_metal_output_buffer() {
4288        let session = MetalBackendSession::system_default().expect("Metal backend session");
4289        let dimensions = (128, 128);
4290        let roi = Rect {
4291            x: 9,
4292            y: 11,
4293            w: 73,
4294            h: 67,
4295        };
4296        let scale = Downscale::Half;
4297        let scaled = roi.scaled_covering(scale);
4298        let rgb = j2k_test_support::patterned_rgb8(dimensions.0, dimensions.1);
4299        let jpeg = encode_jpeg_baseline(
4300            JpegSamples::Rgb8 {
4301                data: &rgb,
4302                width: dimensions.0,
4303                height: dimensions.1,
4304            },
4305            JpegEncodeOptions {
4306                quality: 90,
4307                subsampling: JpegSubsampling::Ybr420,
4308                restart_interval: Some(4),
4309                backend: JpegBackend::Cpu,
4310            },
4311        )
4312        .expect("encode restart-coded fast420 region-scaled jpeg");
4313        let packet = build_fast420_packet(&jpeg.data).expect("restart fast420 packet");
4314        assert_ne!(packet.restart_interval_mcus, 0);
4315        assert!(!packet.restart_offsets.is_empty());
4316
4317        let output = MetalBatchOutputBuffer::new_rgb8_tiles(&session, (scaled.w, scaled.h), 2)
4318            .expect("output buffer");
4319        let inputs = [jpeg.data.as_slice(), jpeg.data.as_slice()];
4320        let (expected, _) = CpuDecoder::new(&jpeg.data)
4321            .expect("cpu decoder")
4322            .decode_region_scaled(
4323                PixelFormat::Rgb8,
4324                j2k_jpeg::Rect {
4325                    x: roi.x,
4326                    y: roi.y,
4327                    w: roi.w,
4328                    h: roi.h,
4329                },
4330                scale,
4331            )
4332            .expect("cpu region-scaled decode");
4333
4334        let surfaces = decode_rgb8_region_scaled_batch_into_metal_buffer_with_session(
4335            &inputs, roi, scale, &output, &session,
4336        )
4337        .expect("decode restart-coded region-scaled tiles into reusable output buffer");
4338
4339        assert_eq!(surfaces.len(), 2);
4340        for (index, surface) in surfaces.into_iter().enumerate() {
4341            let surface = surface.expect("surface");
4342            assert_eq!(surface.residency(), SurfaceResidency::MetalResidentDecode);
4343            assert_eq!(surface.dimensions(), (scaled.w, scaled.h));
4344            assert_eq!(surface.pixel_format(), PixelFormat::Rgb8);
4345            let (buffer, offset) = surface.metal_buffer().expect("metal buffer");
4346            assert!(std::ptr::eq(buffer.as_ref(), output.buffer()));
4347            assert_eq!(offset, index * output.tile_stride_bytes());
4348            assert_eq!(surface.as_bytes(), expected.as_slice());
4349        }
4350    }
4351
4352    #[cfg(target_os = "macos")]
4353    fn assert_restart_region_scaled_buffer_batch_writes_reusable_metal_output(
4354        subsampling: JpegSubsampling,
4355        dimensions: (u32, u32),
4356    ) {
4357        let session = MetalBackendSession::system_default().expect("Metal backend session");
4358        let roi = Rect {
4359            x: 0,
4360            y: 0,
4361            w: dimensions.0,
4362            h: dimensions.1,
4363        };
4364        let scale = Downscale::Half;
4365        let scaled = roi.scaled_covering(scale);
4366        let rgb = j2k_test_support::patterned_rgb8(dimensions.0, dimensions.1);
4367        let jpeg = encode_jpeg_baseline(
4368            JpegSamples::Rgb8 {
4369                data: &rgb,
4370                width: dimensions.0,
4371                height: dimensions.1,
4372            },
4373            JpegEncodeOptions {
4374                quality: 90,
4375                subsampling,
4376                restart_interval: Some(256),
4377                backend: JpegBackend::Cpu,
4378            },
4379        )
4380        .expect("encode restart-coded region-scaled jpeg");
4381        match subsampling {
4382            JpegSubsampling::Ybr422 => {
4383                let packet = build_fast422_packet(&jpeg.data).expect("restart fast422 packet");
4384                assert_ne!(packet.restart_interval_mcus, 0);
4385                assert!(!packet.restart_offsets.is_empty());
4386            }
4387            JpegSubsampling::Ybr444 => {
4388                let packet = build_fast444_packet(&jpeg.data).expect("restart fast444 packet");
4389                assert_ne!(packet.restart_interval_mcus, 0);
4390                assert!(!packet.restart_offsets.is_empty());
4391            }
4392            _ => panic!("restart region-scaled buffer helper expects fast422 or fast444"),
4393        }
4394
4395        let output = MetalBatchOutputBuffer::new_rgb8_tiles(&session, (scaled.w, scaled.h), 2)
4396            .expect("output buffer");
4397        let inputs = [jpeg.data.as_slice(), jpeg.data.as_slice()];
4398        let (expected, _) = CpuDecoder::new(&jpeg.data)
4399            .expect("cpu decoder")
4400            .decode_region_scaled(
4401                PixelFormat::Rgb8,
4402                j2k_jpeg::Rect {
4403                    x: roi.x,
4404                    y: roi.y,
4405                    w: roi.w,
4406                    h: roi.h,
4407                },
4408                scale,
4409            )
4410            .expect("cpu region-scaled decode");
4411
4412        let surfaces = decode_rgb8_region_scaled_batch_into_metal_buffer_with_session(
4413            &inputs, roi, scale, &output, &session,
4414        )
4415        .expect("decode restart-coded region-scaled tiles into reusable output buffer");
4416
4417        assert_eq!(surfaces.len(), 2);
4418        for (index, surface) in surfaces.into_iter().enumerate() {
4419            let surface = surface.expect("surface");
4420            assert_eq!(surface.residency(), SurfaceResidency::MetalResidentDecode);
4421            assert_eq!(surface.dimensions(), (scaled.w, scaled.h));
4422            assert_eq!(surface.pixel_format(), PixelFormat::Rgb8);
4423            let (buffer, offset) = surface.metal_buffer().expect("metal buffer");
4424            assert!(std::ptr::eq(buffer.as_ref(), output.buffer()));
4425            assert_eq!(offset, index * output.tile_stride_bytes());
4426            assert_eq!(surface.as_bytes(), expected.as_slice());
4427        }
4428    }
4429
4430    #[cfg(target_os = "macos")]
4431    #[test]
4432    fn rgb8_restart_fast422_region_scaled_batch_decode_writes_reusable_metal_output_buffer() {
4433        assert_restart_region_scaled_buffer_batch_writes_reusable_metal_output(
4434            JpegSubsampling::Ybr422,
4435            (128, 96),
4436        );
4437    }
4438
4439    #[cfg(target_os = "macos")]
4440    #[test]
4441    fn rgb8_restart_fast444_region_scaled_batch_decode_writes_reusable_metal_output_buffer() {
4442        assert_restart_region_scaled_buffer_batch_writes_reusable_metal_output(
4443            JpegSubsampling::Ybr444,
4444            (96, 96),
4445        );
4446    }
4447
4448    #[cfg(target_os = "macos")]
4449    fn assert_table_mixed_region_scaled_buffer_groups_resident(
4450        subsampling: JpegSubsampling,
4451        dimensions: (u32, u32),
4452        first_quality: u8,
4453        second_quality: u8,
4454    ) {
4455        let session = MetalBackendSession::system_default().expect("Metal backend session");
4456        let roi = Rect {
4457            x: 0,
4458            y: 0,
4459            w: dimensions.0,
4460            h: dimensions.1,
4461        };
4462        let scale = Downscale::Half;
4463        let scaled = roi.scaled_covering(scale);
4464        let rgb_a = j2k_test_support::patterned_rgb8(dimensions.0, dimensions.1);
4465        let mut rgb_b = j2k_test_support::patterned_rgb8(dimensions.0, dimensions.1);
4466        let mut rgb_c = j2k_test_support::patterned_rgb8(dimensions.0, dimensions.1);
4467        for (index, pixel) in rgb_b.chunks_exact_mut(3).enumerate() {
4468            let delta = patterned_index_byte(index)
4469                .wrapping_mul(37)
4470                .wrapping_add(19);
4471            pixel[0] = pixel[0].wrapping_add(delta.rotate_left(1));
4472            pixel[1] ^= delta;
4473            pixel[2] = pixel[2].wrapping_sub(delta.rotate_right(2));
4474        }
4475        for (index, pixel) in rgb_c.chunks_exact_mut(3).enumerate() {
4476            let delta = patterned_index_byte(index)
4477                .wrapping_mul(53)
4478                .wrapping_add(11);
4479            pixel[0] ^= delta.rotate_right(1);
4480            pixel[1] = pixel[1].wrapping_sub(delta.rotate_left(2));
4481            pixel[2] = pixel[2].wrapping_add(delta);
4482        }
4483
4484        let jpeg_a = encode_jpeg_baseline(
4485            JpegSamples::Rgb8 {
4486                data: &rgb_a,
4487                width: dimensions.0,
4488                height: dimensions.1,
4489            },
4490            JpegEncodeOptions {
4491                quality: first_quality,
4492                subsampling,
4493                restart_interval: None,
4494                backend: JpegBackend::Cpu,
4495            },
4496        )
4497        .expect("encode first table-mixed region-scaled buffer jpeg");
4498        let jpeg_b = encode_jpeg_baseline(
4499            JpegSamples::Rgb8 {
4500                data: &rgb_b,
4501                width: dimensions.0,
4502                height: dimensions.1,
4503            },
4504            JpegEncodeOptions {
4505                quality: second_quality,
4506                subsampling,
4507                restart_interval: None,
4508                backend: JpegBackend::Cpu,
4509            },
4510        )
4511        .expect("encode second table-mixed region-scaled buffer jpeg");
4512        let jpeg_c = encode_jpeg_baseline(
4513            JpegSamples::Rgb8 {
4514                data: &rgb_c,
4515                width: dimensions.0,
4516                height: dimensions.1,
4517            },
4518            JpegEncodeOptions {
4519                quality: first_quality,
4520                subsampling,
4521                restart_interval: None,
4522                backend: JpegBackend::Cpu,
4523            },
4524        )
4525        .expect("encode third table-mixed region-scaled buffer jpeg");
4526
4527        match subsampling {
4528            JpegSubsampling::Ybr420 => {
4529                let packet_a = build_fast420_packet(&jpeg_a.data).expect("first packet");
4530                let packet_b = build_fast420_packet(&jpeg_b.data).expect("second packet");
4531                let packet_c = build_fast420_packet(&jpeg_c.data).expect("third packet");
4532                assert_eq!(packet_a.y_quant, packet_c.y_quant);
4533                assert_eq!(packet_a.y_dc_table, packet_c.y_dc_table);
4534                assert_eq!(
4535                    packet_a.entropy_checkpoints.len(),
4536                    packet_c.entropy_checkpoints.len()
4537                );
4538                assert_ne!(packet_a.y_quant, packet_b.y_quant);
4539            }
4540            JpegSubsampling::Ybr422 => {
4541                let packet_a = build_fast422_packet(&jpeg_a.data).expect("first packet");
4542                let packet_b = build_fast422_packet(&jpeg_b.data).expect("second packet");
4543                let packet_c = build_fast422_packet(&jpeg_c.data).expect("third packet");
4544                assert_eq!(packet_a.y_quant, packet_c.y_quant);
4545                assert_eq!(packet_a.y_dc_table, packet_c.y_dc_table);
4546                assert_eq!(
4547                    packet_a.entropy_checkpoints.len(),
4548                    packet_c.entropy_checkpoints.len()
4549                );
4550                assert_ne!(packet_a.y_quant, packet_b.y_quant);
4551            }
4552            JpegSubsampling::Ybr444 => {
4553                let packet_a = build_fast444_packet(&jpeg_a.data).expect("first packet");
4554                let packet_b = build_fast444_packet(&jpeg_b.data).expect("second packet");
4555                let packet_c = build_fast444_packet(&jpeg_c.data).expect("third packet");
4556                assert_eq!(packet_a.y_quant, packet_c.y_quant);
4557                assert_eq!(packet_a.y_dc_table, packet_c.y_dc_table);
4558                assert_eq!(
4559                    packet_a.entropy_checkpoints.len(),
4560                    packet_c.entropy_checkpoints.len()
4561                );
4562                assert_ne!(packet_a.y_quant, packet_b.y_quant);
4563            }
4564            JpegSubsampling::Gray => panic!("table-mixed buffer helper expects YCbCr sampling"),
4565        }
4566
4567        let output = MetalBatchOutputBuffer::new_rgb8_tiles(&session, (scaled.w, scaled.h), 3)
4568            .expect("output buffer");
4569        let inputs = [
4570            jpeg_a.data.as_slice(),
4571            jpeg_b.data.as_slice(),
4572            jpeg_c.data.as_slice(),
4573        ];
4574        let expected_tiles = inputs
4575            .iter()
4576            .map(|input| {
4577                CpuDecoder::new(input)
4578                    .expect("cpu decoder")
4579                    .decode_region_scaled(
4580                        PixelFormat::Rgb8,
4581                        j2k_jpeg::Rect {
4582                            x: roi.x,
4583                            y: roi.y,
4584                            w: roi.w,
4585                            h: roi.h,
4586                        },
4587                        scale,
4588                    )
4589                    .expect("cpu region-scaled decode")
4590                    .0
4591            })
4592            .collect::<Vec<_>>();
4593        assert_ne!(expected_tiles[0], expected_tiles[1]);
4594        assert_ne!(expected_tiles[0], expected_tiles[2]);
4595        assert_ne!(expected_tiles[1], expected_tiles[2]);
4596
4597        let surfaces = decode_rgb8_region_scaled_batch_into_metal_buffer_with_session(
4598            &inputs, roi, scale, &output, &session,
4599        )
4600        .expect("decode table-mixed region-scaled tiles into reusable output buffer");
4601
4602        assert_eq!(surfaces.len(), 3);
4603        for (index, surface) in surfaces.into_iter().enumerate() {
4604            let surface = surface.expect("surface");
4605            assert_eq!(surface.residency(), SurfaceResidency::MetalResidentDecode);
4606            assert_eq!(surface.dimensions(), (scaled.w, scaled.h));
4607            assert_eq!(surface.pixel_format(), PixelFormat::Rgb8);
4608            let (buffer, offset) = surface.metal_buffer().expect("metal buffer");
4609            assert!(std::ptr::eq(buffer.as_ref(), output.buffer()));
4610            assert_eq!(offset, index * output.tile_stride_bytes());
4611            assert_eq!(surface.as_bytes(), expected_tiles[index].as_slice());
4612        }
4613    }
4614
4615    #[cfg(target_os = "macos")]
4616    #[test]
4617    fn rgb8_table_mixed_fast420_region_scaled_buffer_batch_groups_resident_dispatches() {
4618        assert_table_mixed_region_scaled_buffer_groups_resident(
4619            JpegSubsampling::Ybr420,
4620            (128, 96),
4621            90,
4622            72,
4623        );
4624    }
4625
4626    #[cfg(target_os = "macos")]
4627    #[test]
4628    fn rgb8_table_mixed_fast422_region_scaled_buffer_batch_groups_resident_dispatches() {
4629        assert_table_mixed_region_scaled_buffer_groups_resident(
4630            JpegSubsampling::Ybr422,
4631            (128, 96),
4632            91,
4633            73,
4634        );
4635    }
4636
4637    #[cfg(target_os = "macos")]
4638    #[test]
4639    fn rgb8_table_mixed_fast444_region_scaled_buffer_batch_groups_resident_dispatches() {
4640        assert_table_mixed_region_scaled_buffer_groups_resident(
4641            JpegSubsampling::Ybr444,
4642            (96, 96),
4643            92,
4644            74,
4645        );
4646    }
4647
4648    #[cfg(target_os = "macos")]
4649    #[test]
4650    fn rgb8_fast444_region_scaled_batch_decode_can_write_into_reusable_metal_textures() {
4651        let session = MetalBackendSession::system_default().expect("Metal backend session");
4652        let roi = Rect {
4653            x: 1,
4654            y: 2,
4655            w: 5,
4656            h: 4,
4657        };
4658        let scale = Downscale::Quarter;
4659        let scaled = roi.scaled_covering(scale);
4660        let output = MetalBatchTextureOutput::new_rgba8_tiles(&session, (scaled.w, scaled.h), 2)
4661            .expect("texture output");
4662        let inputs = [BASELINE_444, BASELINE_444];
4663        let (expected_rgb, _) = CpuDecoder::new(BASELINE_444)
4664            .expect("cpu decoder")
4665            .decode_region_scaled(
4666                PixelFormat::Rgb8,
4667                j2k_jpeg::Rect {
4668                    x: roi.x,
4669                    y: roi.y,
4670                    w: roi.w,
4671                    h: roi.h,
4672                },
4673                scale,
4674            )
4675            .expect("cpu region scaled decode");
4676        let expected_rgba = rgb_to_rgba_opaque(&expected_rgb);
4677
4678        let tiles = decode_rgb8_region_scaled_batch_into_metal_textures_with_session(
4679            &inputs, roi, scale, &output, &session,
4680        )
4681        .expect("decode region scaled into reusable textures");
4682
4683        assert_eq!(tiles.len(), 2);
4684        assert_eq!(output.tile_capacity(), 2);
4685        assert_eq!(output.dimensions(), (scaled.w, scaled.h));
4686        assert_eq!(output.pixel_format(), PixelFormat::Rgba8);
4687        for (index, tile) in tiles.into_iter().enumerate() {
4688            let tile = tile.expect("texture tile");
4689            assert_eq!(tile.dimensions(), (scaled.w, scaled.h));
4690            assert_eq!(tile.pixel_format(), PixelFormat::Rgba8);
4691            assert!(std::ptr::eq(
4692                tile.texture(),
4693                output.texture(index).expect("output texture")
4694            ));
4695            assert_eq!(
4696                download_rgba8_texture(&session, tile.texture(), tile.dimensions()),
4697                expected_rgba
4698            );
4699        }
4700    }
4701
4702    #[cfg(target_os = "macos")]
4703    #[test]
4704    fn metal_batch_output_buffer_ensure_reuses_matching_allocation_and_grows_capacity() {
4705        use metal::foreign_types::ForeignTypeRef;
4706
4707        let session = MetalBackendSession::system_default().expect("Metal backend session");
4708        let mut output =
4709            MetalBatchOutputBuffer::new_rgb8_tiles(&session, (16, 16), 2).expect("output buffer");
4710        let original_buffer = output.buffer().as_ptr();
4711
4712        output
4713            .ensure_rgb8_tiles(&session, (16, 16), 1)
4714            .expect("ensure smaller matching output");
4715        assert_eq!(output.buffer().as_ptr(), original_buffer);
4716        assert_eq!(output.dimensions(), (16, 16));
4717        assert_eq!(output.tile_capacity(), 2);
4718
4719        output
4720            .ensure_rgb8_tiles(&session, (16, 16), 3)
4721            .expect("ensure larger output");
4722        assert_ne!(output.buffer().as_ptr(), original_buffer);
4723        assert_eq!(output.dimensions(), (16, 16));
4724        assert_eq!(output.tile_capacity(), 3);
4725        assert_eq!(
4726            output.byte_len(),
4727            16 * 16 * PixelFormat::Rgb8.bytes_per_pixel() * 3
4728        );
4729    }
4730
4731    #[cfg(target_os = "macos")]
4732    #[test]
4733    fn metal_batch_texture_output_ensure_reuses_matching_textures_and_grows_capacity() {
4734        use metal::foreign_types::ForeignTypeRef;
4735
4736        let session = MetalBackendSession::system_default().expect("Metal backend session");
4737        let mut output = MetalBatchTextureOutput::new_rgba8_tiles(&session, (16, 16), 2)
4738            .expect("texture output");
4739        let original_texture = output.texture(0).expect("texture").as_ptr();
4740
4741        output
4742            .ensure_rgba8_tiles(&session, (16, 16), 1)
4743            .expect("ensure smaller matching texture output");
4744        assert_eq!(
4745            output.texture(0).expect("texture").as_ptr(),
4746            original_texture
4747        );
4748        assert_eq!(output.dimensions(), (16, 16));
4749        assert_eq!(output.tile_capacity(), 2);
4750
4751        output
4752            .ensure_rgba8_tiles(&session, (16, 16), 3)
4753            .expect("ensure larger texture output");
4754        assert_ne!(
4755            output.texture(0).expect("texture").as_ptr(),
4756            original_texture
4757        );
4758        assert_eq!(output.dimensions(), (16, 16));
4759        assert_eq!(output.tile_capacity(), 3);
4760        assert_eq!(output.pixel_format(), PixelFormat::Rgba8);
4761    }
4762
4763    #[cfg(target_os = "macos")]
4764    #[test]
4765    fn metal_batch_output_buffer_ensure_region_scaled_tiles_uses_scaled_roi_shape() {
4766        let session = MetalBackendSession::system_default().expect("Metal backend session");
4767        let roi = Rect {
4768            x: 4,
4769            y: 4,
4770            w: 10,
4771            h: 10,
4772        };
4773        let scaled = roi.scaled_covering(Downscale::Quarter);
4774        let mut output =
4775            MetalBatchOutputBuffer::new_rgb8_tiles(&session, (1, 1), 1).expect("output buffer");
4776
4777        output
4778            .ensure_rgb8_region_scaled_tiles(&session, roi, Downscale::Quarter, 2)
4779            .expect("ensure region-scaled output");
4780
4781        assert_eq!(output.dimensions(), (scaled.w, scaled.h));
4782        assert_eq!(output.tile_capacity(), 2);
4783        assert_eq!(
4784            output.tile_stride_bytes(),
4785            scaled.w as usize * scaled.h as usize * PixelFormat::Rgb8.bytes_per_pixel()
4786        );
4787    }
4788
4789    #[cfg(target_os = "macos")]
4790    #[test]
4791    fn metal_batch_texture_output_ensure_scaled_tiles_uses_scaled_full_shape() {
4792        let session = MetalBackendSession::system_default().expect("Metal backend session");
4793        let mut output =
4794            MetalBatchTextureOutput::new_rgba8_tiles(&session, (1, 1), 1).expect("texture output");
4795
4796        output
4797            .ensure_rgba8_scaled_tiles(&session, (16, 16), Downscale::Quarter, 2)
4798            .expect("ensure scaled texture output");
4799
4800        assert_eq!(output.dimensions(), (4, 4));
4801        assert_eq!(output.tile_capacity(), 2);
4802        assert_eq!(output.pixel_format(), PixelFormat::Rgba8);
4803    }
4804
4805    #[cfg(target_os = "macos")]
4806    #[test]
4807    fn metal_batch_outputs_can_ensure_from_resident_batch_report() {
4808        let session = MetalBackendSession::system_default().expect("Metal backend session");
4809        let first = Decoder::new(BASELINE_420).expect("first decoder");
4810        let second = Decoder::new(BASELINE_420).expect("second decoder");
4811        let decoders = [&first, &second];
4812        let report = Codec::inspect_rgb8_decoder_batch_metal_output(
4813            &decoders,
4814            j2k_jpeg::JpegDecodeOp::Scaled(Downscale::Quarter),
4815        );
4816        assert!(report.eligibility.eligible);
4817
4818        let mut buffer =
4819            MetalBatchOutputBuffer::new_rgb8_tiles(&session, (1, 1), 1).expect("output buffer");
4820        let mut textures =
4821            MetalBatchTextureOutput::new_rgba8_tiles(&session, (1, 1), 1).expect("texture output");
4822
4823        buffer
4824            .ensure_rgb8_batch_report(&session, &report)
4825            .expect("ensure buffer from report");
4826        textures
4827            .ensure_rgba8_batch_report(&session, &report)
4828            .expect("ensure textures from report");
4829
4830        assert_eq!(buffer.dimensions(), (4, 4));
4831        assert_eq!(buffer.tile_capacity(), 2);
4832        assert_eq!(
4833            buffer.tile_stride_bytes(),
4834            4 * 4 * PixelFormat::Rgb8.bytes_per_pixel()
4835        );
4836        assert_eq!(textures.dimensions(), (4, 4));
4837        assert_eq!(textures.tile_capacity(), 2);
4838        assert_eq!(textures.pixel_format(), PixelFormat::Rgba8);
4839    }
4840
4841    #[cfg(target_os = "macos")]
4842    #[test]
4843    fn metal_batch_outputs_reject_ineligible_report_without_resizing() {
4844        let session = MetalBackendSession::system_default().expect("Metal backend session");
4845        let first = Decoder::new(BASELINE_420).expect("first decoder");
4846        let second = Decoder::new(BASELINE_444).expect("second decoder");
4847        let decoders = [&first, &second];
4848        let report =
4849            Codec::inspect_rgb8_decoder_batch_metal_output(&decoders, j2k_jpeg::JpegDecodeOp::Full);
4850        assert!(!report.eligibility.eligible);
4851
4852        let mut buffer =
4853            MetalBatchOutputBuffer::new_rgb8_tiles(&session, (1, 1), 1).expect("output buffer");
4854        let mut textures =
4855            MetalBatchTextureOutput::new_rgba8_tiles(&session, (1, 1), 1).expect("texture output");
4856
4857        let buffer_err = buffer
4858            .ensure_rgb8_batch_report(&session, &report)
4859            .expect_err("ineligible report should reject buffer ensure");
4860        let texture_err = textures
4861            .ensure_rgba8_batch_report(&session, &report)
4862            .expect_err("ineligible report should reject texture ensure");
4863
4864        assert!(matches!(
4865            buffer_err,
4866            Error::UnsupportedMetalRequest { reason }
4867                if reason.contains("matching output dimensions")
4868        ));
4869        assert!(matches!(
4870            texture_err,
4871            Error::UnsupportedMetalRequest { reason }
4872                if reason.contains("matching output dimensions")
4873        ));
4874        assert_eq!(buffer.dimensions(), (1, 1));
4875        assert_eq!(buffer.tile_capacity(), 1);
4876        assert_eq!(textures.dimensions(), (1, 1));
4877        assert_eq!(textures.tile_capacity(), 1);
4878    }
4879
4880    #[cfg(target_os = "macos")]
4881    #[test]
4882    fn warm_session_reuses_private_intermediate_buffers_for_reusable_output_batches() {
4883        let session = MetalBackendSession::system_default().expect("Metal backend session");
4884        let output =
4885            MetalBatchOutputBuffer::new_rgb8_tiles(&session, (16, 16), 2).expect("output buffer");
4886        let inputs = [BASELINE_420, BASELINE_420];
4887
4888        compute::reset_jpeg_private_buffer_allocations_for_test();
4889        let first = decode_rgb8_batch_into_metal_buffer_with_session(&inputs, &output, &session)
4890            .expect("first decode");
4891        for surface in first {
4892            assert_eq!(
4893                surface.expect("surface").residency(),
4894                SurfaceResidency::MetalResidentDecode
4895            );
4896        }
4897        let allocations_after_first = compute::jpeg_private_buffer_allocations_for_test();
4898
4899        let second = decode_rgb8_batch_into_metal_buffer_with_session(&inputs, &output, &session)
4900            .expect("second decode");
4901        for surface in second {
4902            assert_eq!(
4903                surface.expect("surface").residency(),
4904                SurfaceResidency::MetalResidentDecode
4905            );
4906        }
4907
4908        assert!(
4909            allocations_after_first > 0,
4910            "first batch should allocate private intermediate buffers"
4911        );
4912        assert_eq!(
4913            compute::jpeg_private_buffer_allocations_for_test(),
4914            allocations_after_first,
4915            "warm session batch should reuse private intermediate buffers"
4916        );
4917    }
4918
4919    #[cfg(target_os = "macos")]
4920    #[test]
4921    fn warm_session_reuses_shared_upload_buffers_for_reusable_output_batches() {
4922        let session = MetalBackendSession::system_default().expect("Metal backend session");
4923        let output =
4924            MetalBatchOutputBuffer::new_rgb8_tiles(&session, (16, 16), 2).expect("output buffer");
4925        let inputs = [BASELINE_420, BASELINE_420];
4926
4927        compute::reset_jpeg_shared_buffer_allocations_for_test();
4928        decode_rgb8_batch_into_metal_buffer_with_session(&inputs, &output, &session)
4929            .expect("first decode");
4930        let allocations_after_first = compute::jpeg_shared_buffer_allocations_for_test();
4931
4932        decode_rgb8_batch_into_metal_buffer_with_session(&inputs, &output, &session)
4933            .expect("second decode");
4934
4935        assert!(
4936            allocations_after_first > 0,
4937            "first batch should allocate shared upload/status buffers"
4938        );
4939        assert_eq!(
4940            compute::jpeg_shared_buffer_allocations_for_test(),
4941            allocations_after_first,
4942            "warm session batch should reuse shared upload/status buffers"
4943        );
4944    }
4945
4946    #[cfg(target_os = "macos")]
4947    fn patterned_index_byte(index: usize) -> u8 {
4948        u8::try_from(index % 256).expect("modulo 256 fits in u8")
4949    }
4950
4951    #[cfg(target_os = "macos")]
4952    fn rgb_to_rgba_opaque(rgb: &[u8]) -> Vec<u8> {
4953        let mut rgba = Vec::with_capacity(rgb.len() / 3 * 4);
4954        for pixel in rgb.chunks_exact(3) {
4955            rgba.extend_from_slice(pixel);
4956            rgba.push(u8::MAX);
4957        }
4958        rgba
4959    }
4960
4961    #[cfg(target_os = "macos")]
4962    fn download_rgba8_texture(
4963        session: &MetalBackendSession,
4964        texture: &metal::TextureRef,
4965        dimensions: (u32, u32),
4966    ) -> Vec<u8> {
4967        let row_bytes = dimensions.0 as usize * PixelFormat::Rgba8.bytes_per_pixel();
4968        let byte_len = row_bytes * dimensions.1 as usize;
4969        let buffer = session.device().new_buffer(
4970            byte_len as u64,
4971            metal::MTLResourceOptions::StorageModeShared,
4972        );
4973        let queue = session.device().new_command_queue();
4974        let command_buffer = queue.new_command_buffer();
4975        let blit = command_buffer.new_blit_command_encoder();
4976        blit.copy_from_texture_to_buffer(
4977            texture,
4978            0,
4979            0,
4980            metal::MTLOrigin { x: 0, y: 0, z: 0 },
4981            metal::MTLSize::new(u64::from(dimensions.0), u64::from(dimensions.1), 1),
4982            &buffer,
4983            0,
4984            row_bytes as u64,
4985            byte_len as u64,
4986            metal::MTLBlitOption::None,
4987        );
4988        blit.end_encoding();
4989        command_buffer.commit();
4990        command_buffer.wait_until_completed();
4991
4992        // SAFETY: Metal surface byte views are bounded by validated dimensions and formats.
4993        unsafe { core::slice::from_raw_parts(buffer.contents().cast::<u8>(), byte_len).to_vec() }
4994    }
4995
4996    #[cfg(target_os = "macos")]
4997    #[test]
4998    fn rgb8_fast444_batch_decode_can_write_into_reusable_metal_textures() {
4999        let session = MetalBackendSession::system_default().expect("Metal backend session");
5000        let output =
5001            MetalBatchTextureOutput::new_rgba8_tiles(&session, (8, 8), 2).expect("texture output");
5002        let inputs = [BASELINE_444, BASELINE_444];
5003        let (expected_rgb, _) = CpuDecoder::new(BASELINE_444)
5004            .expect("cpu decoder")
5005            .decode(PixelFormat::Rgb8)
5006            .expect("cpu decode");
5007        let expected_rgba = rgb_to_rgba_opaque(&expected_rgb);
5008
5009        let tiles = decode_rgb8_batch_into_metal_textures_with_session(&inputs, &output, &session)
5010            .expect("decode into reusable textures");
5011
5012        assert_eq!(tiles.len(), 2);
5013        assert_eq!(output.tile_capacity(), 2);
5014        assert_eq!(output.dimensions(), (8, 8));
5015        assert_eq!(output.pixel_format(), PixelFormat::Rgba8);
5016        for (index, tile) in tiles.into_iter().enumerate() {
5017            let tile = tile.expect("texture tile");
5018            assert_eq!(tile.dimensions(), (8, 8));
5019            assert_eq!(tile.pixel_format(), PixelFormat::Rgba8);
5020            assert!(std::ptr::eq(
5021                tile.texture(),
5022                output.texture(index).expect("output texture")
5023            ));
5024            assert_eq!(
5025                download_rgba8_texture(&session, tile.texture(), tile.dimensions()),
5026                expected_rgba
5027            );
5028        }
5029    }
5030
5031    #[cfg(target_os = "macos")]
5032    #[test]
5033    fn rgb8_decoder_batch_resizes_reusable_metal_textures() {
5034        let session = MetalBackendSession::system_default().expect("Metal backend session");
5035        let mut output =
5036            MetalBatchTextureOutput::new_rgba8_tiles(&session, (1, 1), 1).expect("texture output");
5037        let first = Decoder::new(BASELINE_420).expect("first decoder");
5038        let second = Decoder::new(BASELINE_420).expect("second decoder");
5039        let decoders = [&first, &second];
5040        let (expected_rgb, _) = CpuDecoder::new(BASELINE_420)
5041            .expect("cpu decoder")
5042            .decode(PixelFormat::Rgb8)
5043            .expect("cpu decode");
5044        let expected_rgba = rgb_to_rgba_opaque(&expected_rgb);
5045
5046        let tiles = Codec::decode_rgb8_decoder_batch_into_resizable_metal_textures_with_session(
5047            &decoders,
5048            &mut output,
5049            &session,
5050        )
5051        .expect("decode cached decoder batch into resizable reusable textures");
5052
5053        assert_eq!(output.dimensions(), (16, 16));
5054        assert_eq!(output.tile_capacity(), 2);
5055        assert_eq!(tiles.len(), 2);
5056        for (index, tile) in tiles.into_iter().enumerate() {
5057            let tile = tile.expect("texture tile");
5058            assert_eq!(tile.dimensions(), (16, 16));
5059            assert_eq!(tile.pixel_format(), PixelFormat::Rgba8);
5060            assert!(std::ptr::eq(
5061                tile.texture(),
5062                output.texture(index).expect("output texture")
5063            ));
5064            assert_eq!(
5065                download_rgba8_texture(&session, tile.texture(), tile.dimensions()),
5066                expected_rgba
5067            );
5068        }
5069    }
5070
5071    #[cfg(target_os = "macos")]
5072    #[test]
5073    fn rgb8_decoder_batch_can_write_into_fixed_metal_textures() {
5074        let session = MetalBackendSession::system_default().expect("Metal backend session");
5075        let output = MetalBatchTextureOutput::new_rgba8_tiles(&session, (16, 16), 2)
5076            .expect("texture output");
5077        let first = Decoder::new(BASELINE_420).expect("first decoder");
5078        let second = Decoder::new(BASELINE_420).expect("second decoder");
5079        let decoders = [&first, &second];
5080        let (expected_rgb, _) = CpuDecoder::new(BASELINE_420)
5081            .expect("cpu decoder")
5082            .decode(PixelFormat::Rgb8)
5083            .expect("cpu decode");
5084        let expected_rgba = rgb_to_rgba_opaque(&expected_rgb);
5085
5086        let tiles = decode_rgb8_decoder_batch_into_metal_textures_with_session(
5087            &decoders, &output, &session,
5088        )
5089        .expect("decode cached decoder batch into fixed reusable textures");
5090
5091        assert_eq!(tiles.len(), 2);
5092        assert_eq!(output.dimensions(), (16, 16));
5093        assert_eq!(output.tile_capacity(), 2);
5094        for (index, tile) in tiles.into_iter().enumerate() {
5095            let tile = tile.expect("texture tile");
5096            assert_eq!(tile.dimensions(), (16, 16));
5097            assert_eq!(tile.pixel_format(), PixelFormat::Rgba8);
5098            assert!(std::ptr::eq(
5099                tile.texture(),
5100                output.texture(index).expect("output texture")
5101            ));
5102            assert_eq!(
5103                download_rgba8_texture(&session, tile.texture(), tile.dimensions()),
5104                expected_rgba
5105            );
5106        }
5107    }
5108
5109    #[cfg(target_os = "macos")]
5110    #[test]
5111    fn rgb8_decoder_batch_rejects_mixed_output_dimensions_without_resizing_textures() {
5112        let session = MetalBackendSession::system_default().expect("Metal backend session");
5113        let mut output =
5114            MetalBatchTextureOutput::new_rgba8_tiles(&session, (1, 1), 1).expect("texture output");
5115        let first = Decoder::new(BASELINE_420).expect("first decoder");
5116        let second = Decoder::new(BASELINE_444).expect("second decoder");
5117        let decoders = [&first, &second];
5118
5119        let Err(err) = Codec::decode_rgb8_decoder_batch_into_resizable_metal_textures_with_session(
5120            &decoders,
5121            &mut output,
5122            &session,
5123        ) else {
5124            panic!("mixed output dimensions should be rejected");
5125        };
5126
5127        assert!(matches!(err, Error::UnsupportedMetalRequest { .. }));
5128        assert_eq!(output.dimensions(), (1, 1));
5129        assert_eq!(output.tile_capacity(), 1);
5130    }
5131
5132    #[cfg(target_os = "macos")]
5133    #[test]
5134    fn rgb8_decoder_batch_rejects_mixed_sampling_without_resizing_textures() {
5135        let session = MetalBackendSession::system_default().expect("Metal backend session");
5136        let mut output =
5137            MetalBatchTextureOutput::new_rgba8_tiles(&session, (1, 1), 1).expect("texture output");
5138        let rgb = j2k_test_support::patterned_rgb8(16, 16);
5139        let fast420 = encode_jpeg_baseline(
5140            JpegSamples::Rgb8 {
5141                data: &rgb,
5142                width: 16,
5143                height: 16,
5144            },
5145            JpegEncodeOptions {
5146                quality: 90,
5147                subsampling: JpegSubsampling::Ybr420,
5148                restart_interval: None,
5149                backend: JpegBackend::Cpu,
5150            },
5151        )
5152        .expect("encode fast420 jpeg");
5153        let fast444 = encode_jpeg_baseline(
5154            JpegSamples::Rgb8 {
5155                data: &rgb,
5156                width: 16,
5157                height: 16,
5158            },
5159            JpegEncodeOptions {
5160                quality: 90,
5161                subsampling: JpegSubsampling::Ybr444,
5162                restart_interval: None,
5163                backend: JpegBackend::Cpu,
5164            },
5165        )
5166        .expect("encode fast444 jpeg");
5167        let first = Decoder::new(&fast420.data).expect("first decoder");
5168        let second = Decoder::new(&fast444.data).expect("second decoder");
5169        let decoders = [&first, &second];
5170
5171        let Err(err) = Codec::decode_rgb8_decoder_batch_into_resizable_metal_textures_with_session(
5172            &decoders,
5173            &mut output,
5174            &session,
5175        ) else {
5176            panic!("mixed sampling should be rejected");
5177        };
5178
5179        assert!(matches!(
5180            err,
5181            Error::UnsupportedMetalRequest { reason }
5182                if reason.contains("same fast-packet sampling family")
5183        ));
5184        assert_eq!(output.dimensions(), (1, 1));
5185        assert_eq!(output.tile_capacity(), 1);
5186    }
5187
5188    #[cfg(target_os = "macos")]
5189    #[test]
5190    fn rgb8_scaled_batch_decode_can_write_into_reusable_metal_textures() {
5191        let session = MetalBackendSession::system_default().expect("Metal backend session");
5192        let scale = Downscale::Quarter;
5193        let output =
5194            MetalBatchTextureOutput::new_rgba8_tiles(&session, (4, 4), 2).expect("texture output");
5195        let inputs = [BASELINE_420, BASELINE_420];
5196        let (expected_rgb, _) = CpuDecoder::new(BASELINE_420)
5197            .expect("cpu decoder")
5198            .decode_scaled(PixelFormat::Rgb8, scale)
5199            .expect("cpu scaled decode");
5200        let expected_rgba = rgb_to_rgba_opaque(&expected_rgb);
5201
5202        let tiles = decode_rgb8_scaled_batch_into_metal_textures_with_session(
5203            &inputs, scale, &output, &session,
5204        )
5205        .expect("decode scaled into reusable textures");
5206
5207        assert_eq!(tiles.len(), 2);
5208        assert_eq!(output.tile_capacity(), 2);
5209        assert_eq!(output.dimensions(), (4, 4));
5210        assert_eq!(output.pixel_format(), PixelFormat::Rgba8);
5211        for (index, tile) in tiles.into_iter().enumerate() {
5212            let tile = tile.expect("texture tile");
5213            assert_eq!(tile.dimensions(), (4, 4));
5214            assert_eq!(tile.pixel_format(), PixelFormat::Rgba8);
5215            assert!(std::ptr::eq(
5216                tile.texture(),
5217                output.texture(index).expect("output texture")
5218            ));
5219            assert_eq!(
5220                download_rgba8_texture(&session, tile.texture(), tile.dimensions()),
5221                expected_rgba
5222            );
5223        }
5224    }
5225
5226    #[cfg(target_os = "macos")]
5227    #[test]
5228    fn rgb8_scaled_batch_decode_resizes_reusable_metal_textures() {
5229        let session = MetalBackendSession::system_default().expect("Metal backend session");
5230        let scale = Downscale::Quarter;
5231        let mut output =
5232            MetalBatchTextureOutput::new_rgba8_tiles(&session, (1, 1), 1).expect("texture output");
5233        let inputs = [BASELINE_420, BASELINE_420];
5234        let (expected_rgb, _) = CpuDecoder::new(BASELINE_420)
5235            .expect("cpu decoder")
5236            .decode_scaled(PixelFormat::Rgb8, scale)
5237            .expect("cpu scaled decode");
5238        let expected_rgba = rgb_to_rgba_opaque(&expected_rgb);
5239
5240        let tiles = decode_rgb8_scaled_batch_into_resizable_metal_textures_with_session(
5241            &inputs,
5242            scale,
5243            &mut output,
5244            &session,
5245        )
5246        .expect("decode scaled into resizable reusable textures");
5247
5248        assert_eq!(output.dimensions(), (4, 4));
5249        assert_eq!(output.tile_capacity(), 2);
5250        assert_eq!(tiles.len(), 2);
5251        for (index, tile) in tiles.into_iter().enumerate() {
5252            let tile = tile.expect("texture tile");
5253            assert_eq!(tile.dimensions(), (4, 4));
5254            assert_eq!(tile.pixel_format(), PixelFormat::Rgba8);
5255            assert!(std::ptr::eq(
5256                tile.texture(),
5257                output.texture(index).expect("output texture")
5258            ));
5259            assert_eq!(
5260                download_rgba8_texture(&session, tile.texture(), tile.dimensions()),
5261                expected_rgba
5262            );
5263        }
5264    }
5265
5266    #[cfg(target_os = "macos")]
5267    #[test]
5268    fn rgb8_decoder_scaled_batch_resizes_reusable_metal_textures() {
5269        let session = MetalBackendSession::system_default().expect("Metal backend session");
5270        let scale = Downscale::Quarter;
5271        let mut output =
5272            MetalBatchTextureOutput::new_rgba8_tiles(&session, (1, 1), 1).expect("texture output");
5273        let first = Decoder::new(BASELINE_420).expect("first decoder");
5274        let second = Decoder::new(BASELINE_420).expect("second decoder");
5275        let decoders = [&first, &second];
5276        let (expected_rgb, _) = CpuDecoder::new(BASELINE_420)
5277            .expect("cpu decoder")
5278            .decode_scaled(PixelFormat::Rgb8, scale)
5279            .expect("cpu scaled decode");
5280        let expected_rgba = rgb_to_rgba_opaque(&expected_rgb);
5281
5282        let tiles = decode_rgb8_decoder_scaled_batch_into_resizable_metal_textures_with_session(
5283            &decoders,
5284            scale,
5285            &mut output,
5286            &session,
5287        )
5288        .expect("decode cached decoder scaled batch into resizable reusable textures");
5289
5290        assert_eq!(output.dimensions(), (4, 4));
5291        assert_eq!(output.tile_capacity(), 2);
5292        assert_eq!(tiles.len(), 2);
5293        for (index, tile) in tiles.into_iter().enumerate() {
5294            let tile = tile.expect("texture tile");
5295            assert_eq!(tile.dimensions(), (4, 4));
5296            assert_eq!(tile.pixel_format(), PixelFormat::Rgba8);
5297            assert!(std::ptr::eq(
5298                tile.texture(),
5299                output.texture(index).expect("output texture")
5300            ));
5301            assert_eq!(
5302                download_rgba8_texture(&session, tile.texture(), tile.dimensions()),
5303                expected_rgba
5304            );
5305        }
5306    }
5307
5308    #[cfg(target_os = "macos")]
5309    #[test]
5310    fn rgb8_decoder_scaled_batch_can_write_into_fixed_metal_textures() {
5311        let session = MetalBackendSession::system_default().expect("Metal backend session");
5312        let scale = Downscale::Quarter;
5313        let output =
5314            MetalBatchTextureOutput::new_rgba8_tiles(&session, (4, 4), 2).expect("texture output");
5315        let first = Decoder::new(BASELINE_420).expect("first decoder");
5316        let second = Decoder::new(BASELINE_420).expect("second decoder");
5317        let decoders = [&first, &second];
5318        let (expected_rgb, _) = CpuDecoder::new(BASELINE_420)
5319            .expect("cpu decoder")
5320            .decode_scaled(PixelFormat::Rgb8, scale)
5321            .expect("cpu scaled decode");
5322        let expected_rgba = rgb_to_rgba_opaque(&expected_rgb);
5323
5324        let tiles = decode_rgb8_decoder_scaled_batch_into_metal_textures_with_session(
5325            &decoders, scale, &output, &session,
5326        )
5327        .expect("decode cached decoder scaled batch into fixed reusable textures");
5328
5329        assert_eq!(tiles.len(), 2);
5330        assert_eq!(output.dimensions(), (4, 4));
5331        assert_eq!(output.tile_capacity(), 2);
5332        for (index, tile) in tiles.into_iter().enumerate() {
5333            let tile = tile.expect("texture tile");
5334            assert_eq!(tile.dimensions(), (4, 4));
5335            assert_eq!(tile.pixel_format(), PixelFormat::Rgba8);
5336            assert!(std::ptr::eq(
5337                tile.texture(),
5338                output.texture(index).expect("output texture")
5339            ));
5340            assert_eq!(
5341                download_rgba8_texture(&session, tile.texture(), tile.dimensions()),
5342                expected_rgba
5343            );
5344        }
5345    }
5346
5347    #[cfg(target_os = "macos")]
5348    #[test]
5349    fn rgb8_fast422_region_scaled_batch_decode_can_write_into_reusable_metal_textures() {
5350        let session = MetalBackendSession::system_default().expect("Metal backend session");
5351        let roi = Rect {
5352            x: 1,
5353            y: 1,
5354            w: 9,
5355            h: 6,
5356        };
5357        let scale = Downscale::Half;
5358        let scaled = roi.scaled_covering(scale);
5359        let output = MetalBatchTextureOutput::new_rgba8_tiles(&session, (scaled.w, scaled.h), 2)
5360            .expect("texture output");
5361        let inputs = [BASELINE_422, BASELINE_422];
5362        let (expected_rgb, _) = CpuDecoder::new(BASELINE_422)
5363            .expect("cpu decoder")
5364            .decode_region_scaled(
5365                PixelFormat::Rgb8,
5366                j2k_jpeg::Rect {
5367                    x: roi.x,
5368                    y: roi.y,
5369                    w: roi.w,
5370                    h: roi.h,
5371                },
5372                scale,
5373            )
5374            .expect("cpu region scaled decode");
5375        let expected_rgba = rgb_to_rgba_opaque(&expected_rgb);
5376
5377        let tiles = decode_rgb8_region_scaled_batch_into_metal_textures_with_session(
5378            &inputs, roi, scale, &output, &session,
5379        )
5380        .expect("decode region scaled into reusable textures");
5381
5382        assert_eq!(tiles.len(), 2);
5383        assert_eq!(output.tile_capacity(), 2);
5384        assert_eq!(output.dimensions(), (scaled.w, scaled.h));
5385        assert_eq!(output.pixel_format(), PixelFormat::Rgba8);
5386        for (index, tile) in tiles.into_iter().enumerate() {
5387            let tile = tile.expect("texture tile");
5388            assert_eq!(tile.dimensions(), (scaled.w, scaled.h));
5389            assert_eq!(tile.pixel_format(), PixelFormat::Rgba8);
5390            assert!(std::ptr::eq(
5391                tile.texture(),
5392                output.texture(index).expect("output texture")
5393            ));
5394            assert_eq!(
5395                download_rgba8_texture(&session, tile.texture(), tile.dimensions()),
5396                expected_rgba
5397            );
5398        }
5399    }
5400
5401    #[cfg(target_os = "macos")]
5402    #[test]
5403    fn rgb8_table_mixed_fast422_region_scaled_texture_batch_groups_resident_dispatches() {
5404        let session = MetalBackendSession::system_default().expect("Metal backend session");
5405        let dimensions = (128, 96);
5406        let roi = Rect {
5407            x: 0,
5408            y: 0,
5409            w: dimensions.0,
5410            h: dimensions.1,
5411        };
5412        let scale = Downscale::Half;
5413        let scaled = roi.scaled_covering(scale);
5414        let rgb_a = j2k_test_support::patterned_rgb8(dimensions.0, dimensions.1);
5415        let mut rgb_b = j2k_test_support::patterned_rgb8(dimensions.0, dimensions.1);
5416        let mut rgb_c = j2k_test_support::patterned_rgb8(dimensions.0, dimensions.1);
5417        for (index, pixel) in rgb_b.chunks_exact_mut(3).enumerate() {
5418            let delta = patterned_index_byte(index)
5419                .wrapping_mul(41)
5420                .wrapping_add(29);
5421            pixel[0] ^= delta.rotate_left(1);
5422            pixel[1] = pixel[1].wrapping_add(delta);
5423            pixel[2] = pixel[2].wrapping_sub(delta.rotate_right(2));
5424        }
5425        for (index, pixel) in rgb_c.chunks_exact_mut(3).enumerate() {
5426            let delta = patterned_index_byte(index).wrapping_mul(59).wrapping_add(3);
5427            pixel[0] = pixel[0].wrapping_sub(delta.rotate_left(2));
5428            pixel[1] ^= delta.rotate_right(1);
5429            pixel[2] = pixel[2].wrapping_add(delta);
5430        }
5431
5432        let jpeg_a = encode_jpeg_baseline(
5433            JpegSamples::Rgb8 {
5434                data: &rgb_a,
5435                width: dimensions.0,
5436                height: dimensions.1,
5437            },
5438            JpegEncodeOptions {
5439                quality: 90,
5440                subsampling: JpegSubsampling::Ybr422,
5441                restart_interval: None,
5442                backend: JpegBackend::Cpu,
5443            },
5444        )
5445        .expect("encode first fast422 region-scaled table group jpeg");
5446        let jpeg_b = encode_jpeg_baseline(
5447            JpegSamples::Rgb8 {
5448                data: &rgb_b,
5449                width: dimensions.0,
5450                height: dimensions.1,
5451            },
5452            JpegEncodeOptions {
5453                quality: 71,
5454                subsampling: JpegSubsampling::Ybr422,
5455                restart_interval: None,
5456                backend: JpegBackend::Cpu,
5457            },
5458        )
5459        .expect("encode second fast422 region-scaled table group jpeg");
5460        let jpeg_c = encode_jpeg_baseline(
5461            JpegSamples::Rgb8 {
5462                data: &rgb_c,
5463                width: dimensions.0,
5464                height: dimensions.1,
5465            },
5466            JpegEncodeOptions {
5467                quality: 90,
5468                subsampling: JpegSubsampling::Ybr422,
5469                restart_interval: None,
5470                backend: JpegBackend::Cpu,
5471            },
5472        )
5473        .expect("encode third fast422 region-scaled table group jpeg");
5474        let packet_a = build_fast422_packet(&jpeg_a.data).expect("first fast422 packet");
5475        let packet_b = build_fast422_packet(&jpeg_b.data).expect("second fast422 packet");
5476        let packet_c = build_fast422_packet(&jpeg_c.data).expect("third fast422 packet");
5477        assert_eq!(packet_a.y_quant, packet_c.y_quant);
5478        assert_eq!(packet_a.cb_quant, packet_c.cb_quant);
5479        assert_eq!(packet_a.cr_quant, packet_c.cr_quant);
5480        assert_eq!(packet_a.y_dc_table, packet_c.y_dc_table);
5481        assert_eq!(packet_a.y_ac_table, packet_c.y_ac_table);
5482        assert_eq!(
5483            packet_a.entropy_checkpoints.len(),
5484            packet_c.entropy_checkpoints.len()
5485        );
5486        assert_ne!(packet_a.y_quant, packet_b.y_quant);
5487
5488        let output = MetalBatchTextureOutput::new_rgba8_tiles(&session, (scaled.w, scaled.h), 3)
5489            .expect("texture output");
5490        let inputs = [
5491            jpeg_a.data.as_slice(),
5492            jpeg_b.data.as_slice(),
5493            jpeg_c.data.as_slice(),
5494        ];
5495        let (expected_rgb_a, _) = CpuDecoder::new(&jpeg_a.data)
5496            .expect("first cpu decoder")
5497            .decode_region_scaled(
5498                PixelFormat::Rgb8,
5499                j2k_jpeg::Rect {
5500                    x: roi.x,
5501                    y: roi.y,
5502                    w: roi.w,
5503                    h: roi.h,
5504                },
5505                scale,
5506            )
5507            .expect("first cpu region scaled decode");
5508        let (expected_rgb_b, _) = CpuDecoder::new(&jpeg_b.data)
5509            .expect("second cpu decoder")
5510            .decode_region_scaled(
5511                PixelFormat::Rgb8,
5512                j2k_jpeg::Rect {
5513                    x: roi.x,
5514                    y: roi.y,
5515                    w: roi.w,
5516                    h: roi.h,
5517                },
5518                scale,
5519            )
5520            .expect("second cpu region scaled decode");
5521        let (expected_rgb_c, _) = CpuDecoder::new(&jpeg_c.data)
5522            .expect("third cpu decoder")
5523            .decode_region_scaled(
5524                PixelFormat::Rgb8,
5525                j2k_jpeg::Rect {
5526                    x: roi.x,
5527                    y: roi.y,
5528                    w: roi.w,
5529                    h: roi.h,
5530                },
5531                scale,
5532            )
5533            .expect("third cpu region scaled decode");
5534        let expected_tiles = [
5535            rgb_to_rgba_opaque(&expected_rgb_a),
5536            rgb_to_rgba_opaque(&expected_rgb_b),
5537            rgb_to_rgba_opaque(&expected_rgb_c),
5538        ];
5539        assert_ne!(expected_tiles[0], expected_tiles[1]);
5540        assert_ne!(expected_tiles[0], expected_tiles[2]);
5541        assert_ne!(expected_tiles[1], expected_tiles[2]);
5542
5543        let tiles = decode_rgb8_region_scaled_batch_into_metal_textures_with_session(
5544            &inputs, roi, scale, &output, &session,
5545        )
5546        .expect("decode table-mixed fast422 region-scaled tiles into reusable textures");
5547
5548        assert_eq!(tiles.len(), 3);
5549        for (index, tile) in tiles.into_iter().enumerate() {
5550            let tile = tile.expect("texture tile");
5551            assert_eq!(tile.dimensions(), (scaled.w, scaled.h));
5552            assert_eq!(tile.pixel_format(), PixelFormat::Rgba8);
5553            assert!(std::ptr::eq(
5554                tile.texture(),
5555                output.texture(index).expect("output texture")
5556            ));
5557            let actual_rgba = download_rgba8_texture(&session, tile.texture(), tile.dimensions());
5558            assert_eq!(actual_rgba.as_slice(), expected_tiles[index].as_slice());
5559        }
5560    }
5561
5562    #[cfg(target_os = "macos")]
5563    #[test]
5564    fn rgb8_table_mixed_fast444_region_scaled_texture_batch_groups_resident_dispatches() {
5565        let session = MetalBackendSession::system_default().expect("Metal backend session");
5566        let dimensions = (96, 96);
5567        let roi = Rect {
5568            x: 0,
5569            y: 0,
5570            w: dimensions.0,
5571            h: dimensions.1,
5572        };
5573        let scale = Downscale::Half;
5574        let scaled = roi.scaled_covering(scale);
5575        let rgb_a = j2k_test_support::patterned_rgb8(dimensions.0, dimensions.1);
5576        let mut rgb_b = j2k_test_support::patterned_rgb8(dimensions.0, dimensions.1);
5577        let mut rgb_c = j2k_test_support::patterned_rgb8(dimensions.0, dimensions.1);
5578        for (index, pixel) in rgb_b.chunks_exact_mut(3).enumerate() {
5579            let delta = patterned_index_byte(index)
5580                .wrapping_mul(61)
5581                .wrapping_add(13);
5582            pixel[0] = pixel[0].wrapping_add(delta);
5583            pixel[1] ^= delta.rotate_left(1);
5584            pixel[2] = pixel[2].wrapping_sub(delta.rotate_right(2));
5585        }
5586        for (index, pixel) in rgb_c.chunks_exact_mut(3).enumerate() {
5587            let delta = patterned_index_byte(index)
5588                .wrapping_mul(67)
5589                .wrapping_add(31);
5590            pixel[0] = pixel[0].wrapping_sub(delta.rotate_left(2));
5591            pixel[1] = pixel[1].wrapping_add(delta.rotate_right(1));
5592            pixel[2] ^= delta;
5593        }
5594
5595        let jpeg_a = encode_jpeg_baseline(
5596            JpegSamples::Rgb8 {
5597                data: &rgb_a,
5598                width: dimensions.0,
5599                height: dimensions.1,
5600            },
5601            JpegEncodeOptions {
5602                quality: 91,
5603                subsampling: JpegSubsampling::Ybr444,
5604                restart_interval: None,
5605                backend: JpegBackend::Cpu,
5606            },
5607        )
5608        .expect("encode first fast444 region-scaled table group jpeg");
5609        let jpeg_b = encode_jpeg_baseline(
5610            JpegSamples::Rgb8 {
5611                data: &rgb_b,
5612                width: dimensions.0,
5613                height: dimensions.1,
5614            },
5615            JpegEncodeOptions {
5616                quality: 70,
5617                subsampling: JpegSubsampling::Ybr444,
5618                restart_interval: None,
5619                backend: JpegBackend::Cpu,
5620            },
5621        )
5622        .expect("encode second fast444 region-scaled table group jpeg");
5623        let jpeg_c = encode_jpeg_baseline(
5624            JpegSamples::Rgb8 {
5625                data: &rgb_c,
5626                width: dimensions.0,
5627                height: dimensions.1,
5628            },
5629            JpegEncodeOptions {
5630                quality: 91,
5631                subsampling: JpegSubsampling::Ybr444,
5632                restart_interval: None,
5633                backend: JpegBackend::Cpu,
5634            },
5635        )
5636        .expect("encode third fast444 region-scaled table group jpeg");
5637        let packet_a = build_fast444_packet(&jpeg_a.data).expect("first fast444 packet");
5638        let packet_b = build_fast444_packet(&jpeg_b.data).expect("second fast444 packet");
5639        let packet_c = build_fast444_packet(&jpeg_c.data).expect("third fast444 packet");
5640        assert_eq!(packet_a.y_quant, packet_c.y_quant);
5641        assert_eq!(packet_a.cb_quant, packet_c.cb_quant);
5642        assert_eq!(packet_a.cr_quant, packet_c.cr_quant);
5643        assert_eq!(packet_a.y_dc_table, packet_c.y_dc_table);
5644        assert_eq!(packet_a.y_ac_table, packet_c.y_ac_table);
5645        assert_eq!(
5646            packet_a.entropy_checkpoints.len(),
5647            packet_c.entropy_checkpoints.len()
5648        );
5649        assert_ne!(packet_a.y_quant, packet_b.y_quant);
5650
5651        let output = MetalBatchTextureOutput::new_rgba8_tiles(&session, (scaled.w, scaled.h), 3)
5652            .expect("texture output");
5653        let inputs = [
5654            jpeg_a.data.as_slice(),
5655            jpeg_b.data.as_slice(),
5656            jpeg_c.data.as_slice(),
5657        ];
5658        let (expected_rgb_a, _) = CpuDecoder::new(&jpeg_a.data)
5659            .expect("first cpu decoder")
5660            .decode_region_scaled(
5661                PixelFormat::Rgb8,
5662                j2k_jpeg::Rect {
5663                    x: roi.x,
5664                    y: roi.y,
5665                    w: roi.w,
5666                    h: roi.h,
5667                },
5668                scale,
5669            )
5670            .expect("first cpu region scaled decode");
5671        let (expected_rgb_b, _) = CpuDecoder::new(&jpeg_b.data)
5672            .expect("second cpu decoder")
5673            .decode_region_scaled(
5674                PixelFormat::Rgb8,
5675                j2k_jpeg::Rect {
5676                    x: roi.x,
5677                    y: roi.y,
5678                    w: roi.w,
5679                    h: roi.h,
5680                },
5681                scale,
5682            )
5683            .expect("second cpu region scaled decode");
5684        let (expected_rgb_c, _) = CpuDecoder::new(&jpeg_c.data)
5685            .expect("third cpu decoder")
5686            .decode_region_scaled(
5687                PixelFormat::Rgb8,
5688                j2k_jpeg::Rect {
5689                    x: roi.x,
5690                    y: roi.y,
5691                    w: roi.w,
5692                    h: roi.h,
5693                },
5694                scale,
5695            )
5696            .expect("third cpu region scaled decode");
5697        let expected_tiles = [
5698            rgb_to_rgba_opaque(&expected_rgb_a),
5699            rgb_to_rgba_opaque(&expected_rgb_b),
5700            rgb_to_rgba_opaque(&expected_rgb_c),
5701        ];
5702        assert_ne!(expected_tiles[0], expected_tiles[1]);
5703        assert_ne!(expected_tiles[0], expected_tiles[2]);
5704        assert_ne!(expected_tiles[1], expected_tiles[2]);
5705
5706        let tiles = decode_rgb8_region_scaled_batch_into_metal_textures_with_session(
5707            &inputs, roi, scale, &output, &session,
5708        )
5709        .expect("decode table-mixed fast444 region-scaled tiles into reusable textures");
5710
5711        assert_eq!(tiles.len(), 3);
5712        for (index, tile) in tiles.into_iter().enumerate() {
5713            let tile = tile.expect("texture tile");
5714            assert_eq!(tile.dimensions(), (scaled.w, scaled.h));
5715            assert_eq!(tile.pixel_format(), PixelFormat::Rgba8);
5716            assert!(std::ptr::eq(
5717                tile.texture(),
5718                output.texture(index).expect("output texture")
5719            ));
5720            let actual_rgba = download_rgba8_texture(&session, tile.texture(), tile.dimensions());
5721            assert_eq!(actual_rgba.as_slice(), expected_tiles[index].as_slice());
5722        }
5723    }
5724
5725    #[cfg(target_os = "macos")]
5726    #[test]
5727    fn rgb8_fast420_region_scaled_batch_decode_can_write_into_reusable_metal_textures() {
5728        let session = MetalBackendSession::system_default().expect("Metal backend session");
5729        let roi = Rect {
5730            x: 1,
5731            y: 2,
5732            w: 10,
5733            h: 9,
5734        };
5735        let scale = Downscale::Quarter;
5736        let scaled = roi.scaled_covering(scale);
5737        let output = MetalBatchTextureOutput::new_rgba8_tiles(&session, (scaled.w, scaled.h), 2)
5738            .expect("texture output");
5739        let inputs = [BASELINE_420, BASELINE_420];
5740        let (expected_rgb, _) = CpuDecoder::new(BASELINE_420)
5741            .expect("cpu decoder")
5742            .decode_region_scaled(
5743                PixelFormat::Rgb8,
5744                j2k_jpeg::Rect {
5745                    x: roi.x,
5746                    y: roi.y,
5747                    w: roi.w,
5748                    h: roi.h,
5749                },
5750                scale,
5751            )
5752            .expect("cpu region scaled decode");
5753        let expected_rgba = rgb_to_rgba_opaque(&expected_rgb);
5754
5755        let tiles = decode_rgb8_region_scaled_batch_into_metal_textures_with_session(
5756            &inputs, roi, scale, &output, &session,
5757        )
5758        .expect("decode region scaled into reusable textures");
5759
5760        assert_eq!(tiles.len(), 2);
5761        assert_eq!(output.tile_capacity(), 2);
5762        assert_eq!(output.dimensions(), (scaled.w, scaled.h));
5763        assert_eq!(output.pixel_format(), PixelFormat::Rgba8);
5764        for (index, tile) in tiles.into_iter().enumerate() {
5765            let tile = tile.expect("texture tile");
5766            assert_eq!(tile.dimensions(), (scaled.w, scaled.h));
5767            assert_eq!(tile.pixel_format(), PixelFormat::Rgba8);
5768            assert!(std::ptr::eq(
5769                tile.texture(),
5770                output.texture(index).expect("output texture")
5771            ));
5772            assert_eq!(
5773                download_rgba8_texture(&session, tile.texture(), tile.dimensions()),
5774                expected_rgba
5775            );
5776        }
5777    }
5778
5779    #[cfg(target_os = "macos")]
5780    #[test]
5781    fn rgb8_decoder_region_scaled_batch_resizes_reusable_metal_textures() {
5782        let session = MetalBackendSession::system_default().expect("Metal backend session");
5783        let roi = Rect {
5784            x: 1,
5785            y: 2,
5786            w: 10,
5787            h: 9,
5788        };
5789        let scale = Downscale::Quarter;
5790        let scaled = roi.scaled_covering(scale);
5791        let mut output =
5792            MetalBatchTextureOutput::new_rgba8_tiles(&session, (1, 1), 1).expect("texture output");
5793        let first = Decoder::new(BASELINE_420).expect("first decoder");
5794        let second = Decoder::new(BASELINE_420).expect("second decoder");
5795        let decoders = [&first, &second];
5796        let (expected_rgb, _) = CpuDecoder::new(BASELINE_420)
5797            .expect("cpu decoder")
5798            .decode_region_scaled(
5799                PixelFormat::Rgb8,
5800                j2k_jpeg::Rect {
5801                    x: roi.x,
5802                    y: roi.y,
5803                    w: roi.w,
5804                    h: roi.h,
5805                },
5806                scale,
5807            )
5808            .expect("cpu region scaled decode");
5809        let expected_rgba = rgb_to_rgba_opaque(&expected_rgb);
5810
5811        let tiles =
5812            decode_rgb8_decoder_region_scaled_batch_into_resizable_metal_textures_with_session(
5813                &decoders,
5814                roi,
5815                scale,
5816                &mut output,
5817                &session,
5818            )
5819            .expect("decode cached decoder batch into resizable reusable textures");
5820
5821        assert_eq!(output.dimensions(), (scaled.w, scaled.h));
5822        assert_eq!(output.tile_capacity(), 2);
5823        assert_eq!(tiles.len(), 2);
5824        for (index, tile) in tiles.into_iter().enumerate() {
5825            let tile = tile.expect("texture tile");
5826            assert_eq!(tile.dimensions(), (scaled.w, scaled.h));
5827            assert_eq!(tile.pixel_format(), PixelFormat::Rgba8);
5828            assert!(std::ptr::eq(
5829                tile.texture(),
5830                output.texture(index).expect("output texture")
5831            ));
5832            assert_eq!(
5833                download_rgba8_texture(&session, tile.texture(), tile.dimensions()),
5834                expected_rgba
5835            );
5836        }
5837    }
5838
5839    #[cfg(target_os = "macos")]
5840    #[test]
5841    fn rgb8_decoder_region_scaled_batch_can_write_into_fixed_metal_textures() {
5842        let session = MetalBackendSession::system_default().expect("Metal backend session");
5843        let roi = Rect {
5844            x: 1,
5845            y: 2,
5846            w: 10,
5847            h: 9,
5848        };
5849        let scale = Downscale::Quarter;
5850        let scaled = roi.scaled_covering(scale);
5851        let output = MetalBatchTextureOutput::new_rgba8_tiles(&session, (scaled.w, scaled.h), 2)
5852            .expect("texture output");
5853        let first = Decoder::new(BASELINE_420).expect("first decoder");
5854        let second = Decoder::new(BASELINE_420).expect("second decoder");
5855        let decoders = [&first, &second];
5856        let (expected_rgb, _) = CpuDecoder::new(BASELINE_420)
5857            .expect("cpu decoder")
5858            .decode_region_scaled(
5859                PixelFormat::Rgb8,
5860                j2k_jpeg::Rect {
5861                    x: roi.x,
5862                    y: roi.y,
5863                    w: roi.w,
5864                    h: roi.h,
5865                },
5866                scale,
5867            )
5868            .expect("cpu region scaled decode");
5869        let expected_rgba = rgb_to_rgba_opaque(&expected_rgb);
5870
5871        let tiles = decode_rgb8_decoder_region_scaled_batch_into_metal_textures_with_session(
5872            &decoders, roi, scale, &output, &session,
5873        )
5874        .expect("decode cached decoder region-scaled batch into fixed reusable textures");
5875
5876        assert_eq!(tiles.len(), 2);
5877        assert_eq!(output.dimensions(), (scaled.w, scaled.h));
5878        assert_eq!(output.tile_capacity(), 2);
5879        for (index, tile) in tiles.into_iter().enumerate() {
5880            let tile = tile.expect("texture tile");
5881            assert_eq!(tile.dimensions(), (scaled.w, scaled.h));
5882            assert_eq!(tile.pixel_format(), PixelFormat::Rgba8);
5883            assert!(std::ptr::eq(
5884                tile.texture(),
5885                output.texture(index).expect("output texture")
5886            ));
5887            assert_eq!(
5888                download_rgba8_texture(&session, tile.texture(), tile.dimensions()),
5889                expected_rgba
5890            );
5891        }
5892    }
5893
5894    #[cfg(target_os = "macos")]
5895    #[test]
5896    fn rgb8_restart_fast420_region_scaled_batch_decode_writes_reusable_metal_textures() {
5897        let session = MetalBackendSession::system_default().expect("Metal backend session");
5898        let dimensions = (128, 128);
5899        let roi = Rect {
5900            x: 9,
5901            y: 11,
5902            w: 73,
5903            h: 67,
5904        };
5905        let scale = Downscale::Half;
5906        let scaled = roi.scaled_covering(scale);
5907        let rgb = j2k_test_support::patterned_rgb8(dimensions.0, dimensions.1);
5908        let jpeg = encode_jpeg_baseline(
5909            JpegSamples::Rgb8 {
5910                data: &rgb,
5911                width: dimensions.0,
5912                height: dimensions.1,
5913            },
5914            JpegEncodeOptions {
5915                quality: 90,
5916                subsampling: JpegSubsampling::Ybr420,
5917                restart_interval: Some(4),
5918                backend: JpegBackend::Cpu,
5919            },
5920        )
5921        .expect("encode restart-coded fast420 region-scaled texture jpeg");
5922        let packet = build_fast420_packet(&jpeg.data).expect("restart fast420 packet");
5923        assert_ne!(packet.restart_interval_mcus, 0);
5924        assert!(!packet.restart_offsets.is_empty());
5925
5926        let output = MetalBatchTextureOutput::new_rgba8_tiles(&session, (scaled.w, scaled.h), 2)
5927            .expect("texture output");
5928        let inputs = [jpeg.data.as_slice(), jpeg.data.as_slice()];
5929        let (expected_rgb, _) = CpuDecoder::new(&jpeg.data)
5930            .expect("cpu decoder")
5931            .decode_region_scaled(
5932                PixelFormat::Rgb8,
5933                j2k_jpeg::Rect {
5934                    x: roi.x,
5935                    y: roi.y,
5936                    w: roi.w,
5937                    h: roi.h,
5938                },
5939                scale,
5940            )
5941            .expect("cpu region-scaled decode");
5942        let expected_rgba = rgb_to_rgba_opaque(&expected_rgb);
5943
5944        let tiles = decode_rgb8_region_scaled_batch_into_metal_textures_with_session(
5945            &inputs, roi, scale, &output, &session,
5946        )
5947        .expect("decode restart-coded region-scaled tiles into reusable textures");
5948
5949        assert_eq!(tiles.len(), 2);
5950        for (index, tile) in tiles.into_iter().enumerate() {
5951            let tile = tile.expect("texture tile");
5952            assert_eq!(tile.dimensions(), (scaled.w, scaled.h));
5953            assert_eq!(tile.pixel_format(), PixelFormat::Rgba8);
5954            assert!(std::ptr::eq(
5955                tile.texture(),
5956                output.texture(index).expect("output texture")
5957            ));
5958            assert_eq!(
5959                download_rgba8_texture(&session, tile.texture(), tile.dimensions()),
5960                expected_rgba
5961            );
5962        }
5963    }
5964
5965    #[cfg(target_os = "macos")]
5966    fn assert_restart_region_scaled_texture_batch_writes_reusable_metal_output(
5967        subsampling: JpegSubsampling,
5968        dimensions: (u32, u32),
5969    ) {
5970        let session = MetalBackendSession::system_default().expect("Metal backend session");
5971        let roi = Rect {
5972            x: 0,
5973            y: 0,
5974            w: dimensions.0,
5975            h: dimensions.1,
5976        };
5977        let scale = Downscale::Half;
5978        let scaled = roi.scaled_covering(scale);
5979        let rgb = j2k_test_support::patterned_rgb8(dimensions.0, dimensions.1);
5980        let jpeg = encode_jpeg_baseline(
5981            JpegSamples::Rgb8 {
5982                data: &rgb,
5983                width: dimensions.0,
5984                height: dimensions.1,
5985            },
5986            JpegEncodeOptions {
5987                quality: 90,
5988                subsampling,
5989                restart_interval: Some(256),
5990                backend: JpegBackend::Cpu,
5991            },
5992        )
5993        .expect("encode restart-coded region-scaled texture jpeg");
5994        match subsampling {
5995            JpegSubsampling::Ybr422 => {
5996                let packet = build_fast422_packet(&jpeg.data).expect("restart fast422 packet");
5997                assert_ne!(packet.restart_interval_mcus, 0);
5998                assert!(!packet.restart_offsets.is_empty());
5999            }
6000            JpegSubsampling::Ybr444 => {
6001                let packet = build_fast444_packet(&jpeg.data).expect("restart fast444 packet");
6002                assert_ne!(packet.restart_interval_mcus, 0);
6003                assert!(!packet.restart_offsets.is_empty());
6004            }
6005            _ => panic!("restart region-scaled texture helper expects fast422 or fast444"),
6006        }
6007
6008        let output = MetalBatchTextureOutput::new_rgba8_tiles(&session, (scaled.w, scaled.h), 2)
6009            .expect("texture output");
6010        let inputs = [jpeg.data.as_slice(), jpeg.data.as_slice()];
6011        let (expected_rgb, _) = CpuDecoder::new(&jpeg.data)
6012            .expect("cpu decoder")
6013            .decode_region_scaled(
6014                PixelFormat::Rgb8,
6015                j2k_jpeg::Rect {
6016                    x: roi.x,
6017                    y: roi.y,
6018                    w: roi.w,
6019                    h: roi.h,
6020                },
6021                scale,
6022            )
6023            .expect("cpu region-scaled decode");
6024        let expected_rgba = rgb_to_rgba_opaque(&expected_rgb);
6025
6026        let tiles = decode_rgb8_region_scaled_batch_into_metal_textures_with_session(
6027            &inputs, roi, scale, &output, &session,
6028        )
6029        .expect("decode restart-coded region-scaled tiles into reusable textures");
6030
6031        assert_eq!(tiles.len(), 2);
6032        for (index, tile) in tiles.into_iter().enumerate() {
6033            let tile = tile.expect("texture tile");
6034            assert_eq!(tile.dimensions(), (scaled.w, scaled.h));
6035            assert_eq!(tile.pixel_format(), PixelFormat::Rgba8);
6036            assert!(std::ptr::eq(
6037                tile.texture(),
6038                output.texture(index).expect("output texture")
6039            ));
6040            assert_eq!(
6041                download_rgba8_texture(&session, tile.texture(), tile.dimensions()),
6042                expected_rgba
6043            );
6044        }
6045    }
6046
6047    #[cfg(target_os = "macos")]
6048    #[test]
6049    fn rgb8_restart_fast422_region_scaled_batch_decode_writes_reusable_metal_textures() {
6050        assert_restart_region_scaled_texture_batch_writes_reusable_metal_output(
6051            JpegSubsampling::Ybr422,
6052            (128, 96),
6053        );
6054    }
6055
6056    #[cfg(target_os = "macos")]
6057    #[test]
6058    fn rgb8_restart_fast444_region_scaled_batch_decode_writes_reusable_metal_textures() {
6059        assert_restart_region_scaled_texture_batch_writes_reusable_metal_output(
6060            JpegSubsampling::Ybr444,
6061            (96, 96),
6062        );
6063    }
6064
6065    #[cfg(target_os = "macos")]
6066    #[test]
6067    fn rgb8_table_mixed_fast420_region_scaled_texture_batch_groups_resident_dispatches() {
6068        let session = MetalBackendSession::system_default().expect("Metal backend session");
6069        let dimensions = (128, 128);
6070        let roi = Rect {
6071            x: 9,
6072            y: 11,
6073            w: 77,
6074            h: 65,
6075        };
6076        let scale = Downscale::Half;
6077        let scaled = roi.scaled_covering(scale);
6078        let rgb_a = j2k_test_support::patterned_rgb8(dimensions.0, dimensions.1);
6079        let mut rgb_b = j2k_test_support::patterned_rgb8(dimensions.0, dimensions.1);
6080        let mut rgb_c = j2k_test_support::patterned_rgb8(dimensions.0, dimensions.1);
6081        for (index, pixel) in rgb_b.chunks_exact_mut(3).enumerate() {
6082            let delta = patterned_index_byte(index)
6083                .wrapping_mul(43)
6084                .wrapping_add(19);
6085            pixel[0] = pixel[0].wrapping_add(delta.rotate_left(1));
6086            pixel[1] = pixel[1].wrapping_sub(delta);
6087            pixel[2] ^= delta.rotate_right(2);
6088        }
6089        for (index, pixel) in rgb_c.chunks_exact_mut(3).enumerate() {
6090            let delta = patterned_index_byte(index)
6091                .wrapping_mul(47)
6092                .wrapping_add(23);
6093            pixel[0] ^= delta.rotate_left(2);
6094            pixel[1] = pixel[1].wrapping_add(delta.rotate_right(1));
6095            pixel[2] = pixel[2].wrapping_sub(delta);
6096        }
6097
6098        let jpeg_a = encode_jpeg_baseline(
6099            JpegSamples::Rgb8 {
6100                data: &rgb_a,
6101                width: dimensions.0,
6102                height: dimensions.1,
6103            },
6104            JpegEncodeOptions {
6105                quality: 90,
6106                subsampling: JpegSubsampling::Ybr420,
6107                restart_interval: None,
6108                backend: JpegBackend::Cpu,
6109            },
6110        )
6111        .expect("encode first fast420 region-scaled table group jpeg");
6112        let jpeg_b = encode_jpeg_baseline(
6113            JpegSamples::Rgb8 {
6114                data: &rgb_b,
6115                width: dimensions.0,
6116                height: dimensions.1,
6117            },
6118            JpegEncodeOptions {
6119                quality: 72,
6120                subsampling: JpegSubsampling::Ybr420,
6121                restart_interval: None,
6122                backend: JpegBackend::Cpu,
6123            },
6124        )
6125        .expect("encode second fast420 region-scaled table group jpeg");
6126        let jpeg_c = encode_jpeg_baseline(
6127            JpegSamples::Rgb8 {
6128                data: &rgb_c,
6129                width: dimensions.0,
6130                height: dimensions.1,
6131            },
6132            JpegEncodeOptions {
6133                quality: 90,
6134                subsampling: JpegSubsampling::Ybr420,
6135                restart_interval: None,
6136                backend: JpegBackend::Cpu,
6137            },
6138        )
6139        .expect("encode third fast420 region-scaled table group jpeg");
6140        let packet_a = build_fast420_packet(&jpeg_a.data).expect("first fast420 packet");
6141        let packet_b = build_fast420_packet(&jpeg_b.data).expect("second fast420 packet");
6142        let packet_c = build_fast420_packet(&jpeg_c.data).expect("third fast420 packet");
6143        assert_eq!(packet_a.y_quant, packet_c.y_quant);
6144        assert_eq!(packet_a.cb_quant, packet_c.cb_quant);
6145        assert_eq!(packet_a.cr_quant, packet_c.cr_quant);
6146        assert_eq!(packet_a.y_dc_table, packet_c.y_dc_table);
6147        assert_eq!(packet_a.y_ac_table, packet_c.y_ac_table);
6148        assert_eq!(
6149            packet_a.entropy_checkpoints.len(),
6150            packet_c.entropy_checkpoints.len()
6151        );
6152        assert_ne!(packet_a.y_quant, packet_b.y_quant);
6153
6154        let output = MetalBatchTextureOutput::new_rgba8_tiles(&session, (scaled.w, scaled.h), 3)
6155            .expect("texture output");
6156        let inputs = [
6157            jpeg_a.data.as_slice(),
6158            jpeg_b.data.as_slice(),
6159            jpeg_c.data.as_slice(),
6160        ];
6161        let (expected_rgb_a, _) = CpuDecoder::new(&jpeg_a.data)
6162            .expect("first cpu decoder")
6163            .decode_region_scaled(
6164                PixelFormat::Rgb8,
6165                j2k_jpeg::Rect {
6166                    x: roi.x,
6167                    y: roi.y,
6168                    w: roi.w,
6169                    h: roi.h,
6170                },
6171                scale,
6172            )
6173            .expect("first cpu region scaled decode");
6174        let (expected_rgb_b, _) = CpuDecoder::new(&jpeg_b.data)
6175            .expect("second cpu decoder")
6176            .decode_region_scaled(
6177                PixelFormat::Rgb8,
6178                j2k_jpeg::Rect {
6179                    x: roi.x,
6180                    y: roi.y,
6181                    w: roi.w,
6182                    h: roi.h,
6183                },
6184                scale,
6185            )
6186            .expect("second cpu region scaled decode");
6187        let (expected_rgb_c, _) = CpuDecoder::new(&jpeg_c.data)
6188            .expect("third cpu decoder")
6189            .decode_region_scaled(
6190                PixelFormat::Rgb8,
6191                j2k_jpeg::Rect {
6192                    x: roi.x,
6193                    y: roi.y,
6194                    w: roi.w,
6195                    h: roi.h,
6196                },
6197                scale,
6198            )
6199            .expect("third cpu region scaled decode");
6200        let expected_tiles = [
6201            rgb_to_rgba_opaque(&expected_rgb_a),
6202            rgb_to_rgba_opaque(&expected_rgb_b),
6203            rgb_to_rgba_opaque(&expected_rgb_c),
6204        ];
6205        assert_ne!(expected_tiles[0], expected_tiles[1]);
6206        assert_ne!(expected_tiles[0], expected_tiles[2]);
6207        assert_ne!(expected_tiles[1], expected_tiles[2]);
6208
6209        let tiles = decode_rgb8_region_scaled_batch_into_metal_textures_with_session(
6210            &inputs, roi, scale, &output, &session,
6211        )
6212        .expect("decode table-mixed fast420 region-scaled tiles into reusable textures");
6213
6214        assert_eq!(tiles.len(), 3);
6215        for (index, tile) in tiles.into_iter().enumerate() {
6216            let tile = tile.expect("texture tile");
6217            assert_eq!(tile.dimensions(), (scaled.w, scaled.h));
6218            assert_eq!(tile.pixel_format(), PixelFormat::Rgba8);
6219            assert!(std::ptr::eq(
6220                tile.texture(),
6221                output.texture(index).expect("output texture")
6222            ));
6223            let actual_rgba = download_rgba8_texture(&session, tile.texture(), tile.dimensions());
6224            assert_eq!(actual_rgba.as_slice(), expected_tiles[index].as_slice());
6225        }
6226    }
6227
6228    #[cfg(target_os = "macos")]
6229    #[test]
6230    fn rgb8_fast420_batch_decode_can_write_into_reusable_metal_textures() {
6231        let session = MetalBackendSession::system_default().expect("Metal backend session");
6232        let output = MetalBatchTextureOutput::new_rgba8_tiles(&session, (16, 16), 2)
6233            .expect("texture output");
6234        let inputs = [BASELINE_420, BASELINE_420];
6235        let (expected_rgb, _) = CpuDecoder::new(BASELINE_420)
6236            .expect("cpu decoder")
6237            .decode(PixelFormat::Rgb8)
6238            .expect("cpu decode");
6239        let expected_rgba = rgb_to_rgba_opaque(&expected_rgb);
6240
6241        let tiles = decode_rgb8_batch_into_metal_textures_with_session(&inputs, &output, &session)
6242            .expect("decode into reusable textures");
6243
6244        assert_eq!(tiles.len(), 2);
6245        assert_eq!(output.tile_capacity(), 2);
6246        assert_eq!(output.dimensions(), (16, 16));
6247        assert_eq!(output.pixel_format(), PixelFormat::Rgba8);
6248        for (index, tile) in tiles.into_iter().enumerate() {
6249            let tile = tile.expect("texture tile");
6250            assert_eq!(tile.dimensions(), (16, 16));
6251            assert_eq!(tile.pixel_format(), PixelFormat::Rgba8);
6252            assert!(std::ptr::eq(
6253                tile.texture(),
6254                output.texture(index).expect("output texture")
6255            ));
6256            assert_eq!(
6257                download_rgba8_texture(&session, tile.texture(), tile.dimensions()),
6258                expected_rgba
6259            );
6260        }
6261    }
6262
6263    #[cfg(target_os = "macos")]
6264    #[test]
6265    fn rgb8_fast422_batch_decode_can_write_into_reusable_metal_textures() {
6266        let session = MetalBackendSession::system_default().expect("Metal backend session");
6267        let output =
6268            MetalBatchTextureOutput::new_rgba8_tiles(&session, (16, 8), 2).expect("texture output");
6269        let inputs = [BASELINE_422, BASELINE_422];
6270        let (expected_rgb, _) = CpuDecoder::new(BASELINE_422)
6271            .expect("cpu decoder")
6272            .decode(PixelFormat::Rgb8)
6273            .expect("cpu decode");
6274        let expected_rgba = rgb_to_rgba_opaque(&expected_rgb);
6275
6276        let tiles = decode_rgb8_batch_into_metal_textures_with_session(&inputs, &output, &session)
6277            .expect("decode into reusable textures");
6278
6279        assert_eq!(tiles.len(), 2);
6280        assert_eq!(output.tile_capacity(), 2);
6281        assert_eq!(output.dimensions(), (16, 8));
6282        assert_eq!(output.pixel_format(), PixelFormat::Rgba8);
6283        for (index, tile) in tiles.into_iter().enumerate() {
6284            let tile = tile.expect("texture tile");
6285            assert_eq!(tile.dimensions(), (16, 8));
6286            assert_eq!(tile.pixel_format(), PixelFormat::Rgba8);
6287            assert!(std::ptr::eq(
6288                tile.texture(),
6289                output.texture(index).expect("output texture")
6290            ));
6291            assert_eq!(
6292                download_rgba8_texture(&session, tile.texture(), tile.dimensions()),
6293                expected_rgba
6294            );
6295        }
6296    }
6297
6298    #[cfg(target_os = "macos")]
6299    #[test]
6300    fn rgb8_texture_batch_decode_avoids_private_rgba_staging_buffers() {
6301        let cases = [
6302            (BASELINE_420, (16, 16), 0),
6303            (BASELINE_422, (16, 8), 0),
6304            (BASELINE_444, (8, 8), 0),
6305        ];
6306
6307        for (input, dimensions, expected_private_allocations) in cases {
6308            let session = MetalBackendSession::system_default().expect("Metal backend session");
6309            let output = MetalBatchTextureOutput::new_rgba8_tiles(&session, dimensions, 2)
6310                .expect("texture output");
6311            let inputs = [input, input];
6312
6313            compute::reset_jpeg_private_buffer_allocations_for_test();
6314            let tiles =
6315                decode_rgb8_batch_into_metal_textures_with_session(&inputs, &output, &session)
6316                    .expect("decode into reusable textures");
6317            assert_eq!(tiles.len(), 2);
6318            for tile in tiles {
6319                assert_eq!(
6320                    tile.expect("texture tile").pixel_format(),
6321                    PixelFormat::Rgba8
6322                );
6323            }
6324
6325            assert_eq!(
6326                compute::jpeg_private_buffer_allocations_for_test(),
6327                expected_private_allocations,
6328                "texture batch decode should not allocate a private RGBA staging buffer for {dimensions:?}"
6329            );
6330        }
6331    }
6332
6333    #[cfg(target_os = "macos")]
6334    #[test]
6335    fn rgb8_fast444_texture_batch_decode_fuses_directly_into_reusable_metal_textures() {
6336        let session = MetalBackendSession::system_default().expect("Metal backend session");
6337        let output =
6338            MetalBatchTextureOutput::new_rgba8_tiles(&session, (8, 8), 2).expect("texture output");
6339        let inputs = [BASELINE_444, BASELINE_444];
6340        let (expected_rgb, _) = CpuDecoder::new(BASELINE_444)
6341            .expect("cpu decoder")
6342            .decode(PixelFormat::Rgb8)
6343            .expect("cpu decode");
6344        let expected_rgba = rgb_to_rgba_opaque(&expected_rgb);
6345
6346        compute::reset_jpeg_private_buffer_allocations_for_test();
6347        let tiles = decode_rgb8_batch_into_metal_textures_with_session(&inputs, &output, &session)
6348            .expect("decode into reusable textures");
6349
6350        assert_eq!(tiles.len(), 2);
6351        for (index, tile) in tiles.into_iter().enumerate() {
6352            let tile = tile.expect("texture tile");
6353            assert_eq!(tile.dimensions(), (8, 8));
6354            assert_eq!(tile.pixel_format(), PixelFormat::Rgba8);
6355            assert!(std::ptr::eq(
6356                tile.texture(),
6357                output.texture(index).expect("output texture")
6358            ));
6359            assert_eq!(
6360                download_rgba8_texture(&session, tile.texture(), tile.dimensions()),
6361                expected_rgba
6362            );
6363        }
6364        assert_eq!(
6365            compute::jpeg_private_buffer_allocations_for_test(),
6366            0,
6367            "fused 4:4:4 texture batch decode should not allocate private Y/Cb/Cr staging planes"
6368        );
6369    }
6370
6371    #[cfg(target_os = "macos")]
6372    #[test]
6373    fn rgb8_table_mixed_fast444_texture_batch_groups_resident_dispatches() {
6374        let session = MetalBackendSession::system_default().expect("Metal backend session");
6375        let dimensions = (64, 64);
6376        let rgb_a = j2k_test_support::patterned_rgb8(dimensions.0, dimensions.1);
6377        let mut rgb_b = j2k_test_support::patterned_rgb8(dimensions.0, dimensions.1);
6378        let mut rgb_c = j2k_test_support::patterned_rgb8(dimensions.0, dimensions.1);
6379        for (index, pixel) in rgb_b.chunks_exact_mut(3).enumerate() {
6380            let delta = patterned_index_byte(index).wrapping_mul(31).wrapping_add(5);
6381            pixel[0] = pixel[0].wrapping_sub(delta);
6382            pixel[1] = pixel[1].wrapping_add(delta.rotate_left(1));
6383            pixel[2] ^= delta.rotate_right(2);
6384        }
6385        for (index, pixel) in rgb_c.chunks_exact_mut(3).enumerate() {
6386            let delta = patterned_index_byte(index)
6387                .wrapping_mul(37)
6388                .wrapping_add(17);
6389            pixel[0] ^= delta.rotate_left(3);
6390            pixel[1] = pixel[1].wrapping_sub(delta.rotate_right(1));
6391            pixel[2] = pixel[2].wrapping_add(delta);
6392        }
6393
6394        let jpeg_a = encode_jpeg_baseline(
6395            JpegSamples::Rgb8 {
6396                data: &rgb_a,
6397                width: dimensions.0,
6398                height: dimensions.1,
6399            },
6400            JpegEncodeOptions {
6401                quality: 92,
6402                subsampling: JpegSubsampling::Ybr444,
6403                restart_interval: None,
6404                backend: JpegBackend::Cpu,
6405            },
6406        )
6407        .expect("encode first fast444 table group jpeg");
6408        let jpeg_b = encode_jpeg_baseline(
6409            JpegSamples::Rgb8 {
6410                data: &rgb_b,
6411                width: dimensions.0,
6412                height: dimensions.1,
6413            },
6414            JpegEncodeOptions {
6415                quality: 71,
6416                subsampling: JpegSubsampling::Ybr444,
6417                restart_interval: None,
6418                backend: JpegBackend::Cpu,
6419            },
6420        )
6421        .expect("encode second fast444 table group jpeg");
6422        let jpeg_c = encode_jpeg_baseline(
6423            JpegSamples::Rgb8 {
6424                data: &rgb_c,
6425                width: dimensions.0,
6426                height: dimensions.1,
6427            },
6428            JpegEncodeOptions {
6429                quality: 92,
6430                subsampling: JpegSubsampling::Ybr444,
6431                restart_interval: None,
6432                backend: JpegBackend::Cpu,
6433            },
6434        )
6435        .expect("encode third fast444 table group jpeg");
6436        let packet_a = build_fast444_packet(&jpeg_a.data).expect("first fast444 packet");
6437        let packet_b = build_fast444_packet(&jpeg_b.data).expect("second fast444 packet");
6438        let packet_c = build_fast444_packet(&jpeg_c.data).expect("third fast444 packet");
6439        assert_eq!(packet_a.y_quant, packet_c.y_quant);
6440        assert_eq!(packet_a.cb_quant, packet_c.cb_quant);
6441        assert_eq!(packet_a.cr_quant, packet_c.cr_quant);
6442        assert_eq!(packet_a.y_dc_table, packet_c.y_dc_table);
6443        assert_eq!(packet_a.y_ac_table, packet_c.y_ac_table);
6444        assert_eq!(
6445            packet_a.entropy_checkpoints.len(),
6446            packet_c.entropy_checkpoints.len()
6447        );
6448        assert_ne!(packet_a.y_quant, packet_b.y_quant);
6449
6450        let output = MetalBatchTextureOutput::new_rgba8_tiles(&session, dimensions, 3)
6451            .expect("texture output");
6452        let inputs = [
6453            jpeg_a.data.as_slice(),
6454            jpeg_b.data.as_slice(),
6455            jpeg_c.data.as_slice(),
6456        ];
6457        let (expected_rgb_a, _) = CpuDecoder::new(&jpeg_a.data)
6458            .expect("first cpu decoder")
6459            .decode(PixelFormat::Rgb8)
6460            .expect("first cpu decode");
6461        let (expected_rgb_b, _) = CpuDecoder::new(&jpeg_b.data)
6462            .expect("second cpu decoder")
6463            .decode(PixelFormat::Rgb8)
6464            .expect("second cpu decode");
6465        let (expected_rgb_c, _) = CpuDecoder::new(&jpeg_c.data)
6466            .expect("third cpu decoder")
6467            .decode(PixelFormat::Rgb8)
6468            .expect("third cpu decode");
6469        let expected_tiles = [
6470            rgb_to_rgba_opaque(&expected_rgb_a),
6471            rgb_to_rgba_opaque(&expected_rgb_b),
6472            rgb_to_rgba_opaque(&expected_rgb_c),
6473        ];
6474        assert_ne!(expected_tiles[0], expected_tiles[1]);
6475        assert_ne!(expected_tiles[0], expected_tiles[2]);
6476        assert_ne!(expected_tiles[1], expected_tiles[2]);
6477
6478        compute::reset_jpeg_private_buffer_allocations_for_test();
6479        let tiles = decode_rgb8_batch_into_metal_textures_with_session(&inputs, &output, &session)
6480            .expect("decode table-mixed fast444 tiles into reusable textures");
6481
6482        assert_eq!(tiles.len(), 3);
6483        for (index, tile) in tiles.into_iter().enumerate() {
6484            let tile = tile.expect("texture tile");
6485            assert_eq!(tile.dimensions(), dimensions);
6486            assert_eq!(tile.pixel_format(), PixelFormat::Rgba8);
6487            assert!(std::ptr::eq(
6488                tile.texture(),
6489                output.texture(index).expect("output texture")
6490            ));
6491            let actual_rgba = download_rgba8_texture(&session, tile.texture(), tile.dimensions());
6492            assert_eq!(actual_rgba.as_slice(), expected_tiles[index].as_slice());
6493        }
6494        assert_eq!(
6495            compute::jpeg_private_buffer_allocations_for_test(),
6496            0,
6497            "table-mixed resident 4:4:4 texture dispatches should not allocate private Y/Cb/Cr staging planes"
6498        );
6499    }
6500
6501    #[cfg(target_os = "macos")]
6502    #[test]
6503    fn rgb8_fast422_texture_batch_decode_fuses_directly_into_reusable_metal_textures() {
6504        let session = MetalBackendSession::system_default().expect("Metal backend session");
6505        let output =
6506            MetalBatchTextureOutput::new_rgba8_tiles(&session, (16, 8), 2).expect("texture output");
6507        let inputs = [BASELINE_422, BASELINE_422];
6508        let (expected_rgb, _) = CpuDecoder::new(BASELINE_422)
6509            .expect("cpu decoder")
6510            .decode(PixelFormat::Rgb8)
6511            .expect("cpu decode");
6512        let expected_rgba = rgb_to_rgba_opaque(&expected_rgb);
6513
6514        compute::reset_jpeg_private_buffer_allocations_for_test();
6515        let tiles = decode_rgb8_batch_into_metal_textures_with_session(&inputs, &output, &session)
6516            .expect("decode into reusable textures");
6517
6518        assert_eq!(tiles.len(), 2);
6519        for (index, tile) in tiles.into_iter().enumerate() {
6520            let tile = tile.expect("texture tile");
6521            assert_eq!(tile.dimensions(), (16, 8));
6522            assert_eq!(tile.pixel_format(), PixelFormat::Rgba8);
6523            assert!(std::ptr::eq(
6524                tile.texture(),
6525                output.texture(index).expect("output texture")
6526            ));
6527            assert_eq!(
6528                download_rgba8_texture(&session, tile.texture(), tile.dimensions()),
6529                expected_rgba
6530            );
6531        }
6532        assert_eq!(
6533            compute::jpeg_private_buffer_allocations_for_test(),
6534            0,
6535            "fused 4:2:2 texture batch decode should not allocate private Y/Cb/Cr staging planes"
6536        );
6537    }
6538
6539    #[cfg(target_os = "macos")]
6540    #[test]
6541    fn rgb8_wide_fast422_texture_batch_decode_fuses_directly_into_reusable_metal_textures() {
6542        let session = MetalBackendSession::system_default().expect("Metal backend session");
6543        let dimensions = (48, 16);
6544        let rgb = j2k_test_support::patterned_rgb8(dimensions.0, dimensions.1);
6545        let jpeg = encode_jpeg_baseline(
6546            JpegSamples::Rgb8 {
6547                data: &rgb,
6548                width: dimensions.0,
6549                height: dimensions.1,
6550            },
6551            JpegEncodeOptions {
6552                quality: 92,
6553                subsampling: JpegSubsampling::Ybr422,
6554                restart_interval: None,
6555                backend: JpegBackend::Cpu,
6556            },
6557        )
6558        .expect("encode 4:2:2 source jpeg");
6559        let output = MetalBatchTextureOutput::new_rgba8_tiles(&session, dimensions, 2)
6560            .expect("texture output");
6561        let inputs = [jpeg.data.as_slice(), jpeg.data.as_slice()];
6562        let (expected_rgb, _) = CpuDecoder::new(&jpeg.data)
6563            .expect("cpu decoder")
6564            .decode(PixelFormat::Rgb8)
6565            .expect("cpu decode");
6566        let expected_rgba = rgb_to_rgba_opaque(&expected_rgb);
6567
6568        compute::reset_jpeg_private_buffer_allocations_for_test();
6569        let tiles = decode_rgb8_batch_into_metal_textures_with_session(&inputs, &output, &session)
6570            .expect("decode into reusable textures");
6571
6572        assert_eq!(tiles.len(), 2);
6573        for (index, tile) in tiles.into_iter().enumerate() {
6574            let tile = tile.expect("texture tile");
6575            assert_eq!(tile.dimensions(), dimensions);
6576            assert_eq!(tile.pixel_format(), PixelFormat::Rgba8);
6577            assert!(std::ptr::eq(
6578                tile.texture(),
6579                output.texture(index).expect("output texture")
6580            ));
6581            assert_eq!(
6582                download_rgba8_texture(&session, tile.texture(), tile.dimensions()),
6583                expected_rgba
6584            );
6585        }
6586        assert_eq!(
6587            compute::jpeg_private_buffer_allocations_for_test(),
6588            0,
6589            "wide fused 4:2:2 texture batch decode should not allocate private Y/Cb/Cr staging planes"
6590        );
6591    }
6592
6593    #[cfg(target_os = "macos")]
6594    #[test]
6595    fn rgb8_table_mixed_fast422_texture_batch_groups_resident_dispatches() {
6596        let session = MetalBackendSession::system_default().expect("Metal backend session");
6597        let dimensions = (96, 48);
6598        let rgb_a = j2k_test_support::patterned_rgb8(dimensions.0, dimensions.1);
6599        let mut rgb_b = j2k_test_support::patterned_rgb8(dimensions.0, dimensions.1);
6600        let mut rgb_c = j2k_test_support::patterned_rgb8(dimensions.0, dimensions.1);
6601        for (index, pixel) in rgb_b.chunks_exact_mut(3).enumerate() {
6602            let delta = patterned_index_byte(index)
6603                .wrapping_mul(23)
6604                .wrapping_add(11);
6605            pixel[0] = pixel[0].wrapping_add(delta.rotate_left(1));
6606            pixel[1] ^= delta;
6607            pixel[2] = pixel[2].wrapping_sub(delta.rotate_right(2));
6608        }
6609        for (index, pixel) in rgb_c.chunks_exact_mut(3).enumerate() {
6610            let delta = patterned_index_byte(index)
6611                .wrapping_mul(19)
6612                .wrapping_add(53);
6613            pixel[0] ^= delta.rotate_left(2);
6614            pixel[1] = pixel[1].wrapping_sub(delta);
6615            pixel[2] = pixel[2].wrapping_add(delta.rotate_right(1));
6616        }
6617
6618        let jpeg_a = encode_jpeg_baseline(
6619            JpegSamples::Rgb8 {
6620                data: &rgb_a,
6621                width: dimensions.0,
6622                height: dimensions.1,
6623            },
6624            JpegEncodeOptions {
6625                quality: 91,
6626                subsampling: JpegSubsampling::Ybr422,
6627                restart_interval: None,
6628                backend: JpegBackend::Cpu,
6629            },
6630        )
6631        .expect("encode first fast422 table group jpeg");
6632        let jpeg_b = encode_jpeg_baseline(
6633            JpegSamples::Rgb8 {
6634                data: &rgb_b,
6635                width: dimensions.0,
6636                height: dimensions.1,
6637            },
6638            JpegEncodeOptions {
6639                quality: 73,
6640                subsampling: JpegSubsampling::Ybr422,
6641                restart_interval: None,
6642                backend: JpegBackend::Cpu,
6643            },
6644        )
6645        .expect("encode second fast422 table group jpeg");
6646        let jpeg_c = encode_jpeg_baseline(
6647            JpegSamples::Rgb8 {
6648                data: &rgb_c,
6649                width: dimensions.0,
6650                height: dimensions.1,
6651            },
6652            JpegEncodeOptions {
6653                quality: 91,
6654                subsampling: JpegSubsampling::Ybr422,
6655                restart_interval: None,
6656                backend: JpegBackend::Cpu,
6657            },
6658        )
6659        .expect("encode third fast422 table group jpeg");
6660        let packet_a = build_fast422_packet(&jpeg_a.data).expect("first fast422 packet");
6661        let packet_b = build_fast422_packet(&jpeg_b.data).expect("second fast422 packet");
6662        let packet_c = build_fast422_packet(&jpeg_c.data).expect("third fast422 packet");
6663        assert_eq!(packet_a.y_quant, packet_c.y_quant);
6664        assert_eq!(packet_a.cb_quant, packet_c.cb_quant);
6665        assert_eq!(packet_a.cr_quant, packet_c.cr_quant);
6666        assert_eq!(packet_a.y_dc_table, packet_c.y_dc_table);
6667        assert_eq!(packet_a.y_ac_table, packet_c.y_ac_table);
6668        assert_eq!(
6669            packet_a.entropy_checkpoints.len(),
6670            packet_c.entropy_checkpoints.len()
6671        );
6672        assert_ne!(packet_a.y_quant, packet_b.y_quant);
6673
6674        let output = MetalBatchTextureOutput::new_rgba8_tiles(&session, dimensions, 3)
6675            .expect("texture output");
6676        let inputs = [
6677            jpeg_a.data.as_slice(),
6678            jpeg_b.data.as_slice(),
6679            jpeg_c.data.as_slice(),
6680        ];
6681        let (expected_rgb_a, _) = CpuDecoder::new(&jpeg_a.data)
6682            .expect("first cpu decoder")
6683            .decode(PixelFormat::Rgb8)
6684            .expect("first cpu decode");
6685        let (expected_rgb_b, _) = CpuDecoder::new(&jpeg_b.data)
6686            .expect("second cpu decoder")
6687            .decode(PixelFormat::Rgb8)
6688            .expect("second cpu decode");
6689        let (expected_rgb_c, _) = CpuDecoder::new(&jpeg_c.data)
6690            .expect("third cpu decoder")
6691            .decode(PixelFormat::Rgb8)
6692            .expect("third cpu decode");
6693        let expected_tiles = [
6694            rgb_to_rgba_opaque(&expected_rgb_a),
6695            rgb_to_rgba_opaque(&expected_rgb_b),
6696            rgb_to_rgba_opaque(&expected_rgb_c),
6697        ];
6698        assert_ne!(expected_tiles[0], expected_tiles[1]);
6699        assert_ne!(expected_tiles[0], expected_tiles[2]);
6700        assert_ne!(expected_tiles[1], expected_tiles[2]);
6701
6702        compute::reset_jpeg_private_buffer_allocations_for_test();
6703        let tiles = decode_rgb8_batch_into_metal_textures_with_session(&inputs, &output, &session)
6704            .expect("decode table-mixed fast422 tiles into reusable textures");
6705
6706        assert_eq!(tiles.len(), 3);
6707        for (index, tile) in tiles.into_iter().enumerate() {
6708            let tile = tile.expect("texture tile");
6709            assert_eq!(tile.dimensions(), dimensions);
6710            assert_eq!(tile.pixel_format(), PixelFormat::Rgba8);
6711            assert!(std::ptr::eq(
6712                tile.texture(),
6713                output.texture(index).expect("output texture")
6714            ));
6715            let actual_rgba = download_rgba8_texture(&session, tile.texture(), tile.dimensions());
6716            assert_eq!(actual_rgba.as_slice(), expected_tiles[index].as_slice());
6717        }
6718        assert_eq!(
6719            compute::jpeg_private_buffer_allocations_for_test(),
6720            0,
6721            "table-mixed resident 4:2:2 texture dispatches should not allocate private Y/Cb/Cr staging planes"
6722        );
6723    }
6724
6725    #[cfg(target_os = "macos")]
6726    #[test]
6727    fn rgb8_fast420_texture_batch_decode_fuses_directly_into_reusable_metal_textures() {
6728        let session = MetalBackendSession::system_default().expect("Metal backend session");
6729        let output = MetalBatchTextureOutput::new_rgba8_tiles(&session, (16, 16), 2)
6730            .expect("texture output");
6731        let inputs = [BASELINE_420, BASELINE_420];
6732        let (expected_rgb, _) = CpuDecoder::new(BASELINE_420)
6733            .expect("cpu decoder")
6734            .decode(PixelFormat::Rgb8)
6735            .expect("cpu decode");
6736        let expected_rgba = rgb_to_rgba_opaque(&expected_rgb);
6737
6738        compute::reset_jpeg_private_buffer_allocations_for_test();
6739        let tiles = decode_rgb8_batch_into_metal_textures_with_session(&inputs, &output, &session)
6740            .expect("decode into reusable textures");
6741
6742        assert_eq!(tiles.len(), 2);
6743        for (index, tile) in tiles.into_iter().enumerate() {
6744            let tile = tile.expect("texture tile");
6745            assert_eq!(tile.dimensions(), (16, 16));
6746            assert_eq!(tile.pixel_format(), PixelFormat::Rgba8);
6747            assert!(std::ptr::eq(
6748                tile.texture(),
6749                output.texture(index).expect("output texture")
6750            ));
6751            assert_eq!(
6752                download_rgba8_texture(&session, tile.texture(), tile.dimensions()),
6753                expected_rgba
6754            );
6755        }
6756        assert_eq!(
6757            compute::jpeg_private_buffer_allocations_for_test(),
6758            0,
6759            "fused 4:2:0 texture batch decode should not allocate private Y/Cb/Cr staging planes"
6760        );
6761    }
6762
6763    #[cfg(target_os = "macos")]
6764    #[test]
6765    fn rgb8_wide_row_fast420_texture_batch_decode_fuses_directly_into_reusable_metal_textures() {
6766        let session = MetalBackendSession::system_default().expect("Metal backend session");
6767        let dimensions = (32, 16);
6768        let rgb = j2k_test_support::patterned_rgb8(dimensions.0, dimensions.1);
6769        let jpeg = encode_jpeg_baseline(
6770            JpegSamples::Rgb8 {
6771                data: &rgb,
6772                width: dimensions.0,
6773                height: dimensions.1,
6774            },
6775            JpegEncodeOptions {
6776                quality: 92,
6777                subsampling: JpegSubsampling::Ybr420,
6778                restart_interval: None,
6779                backend: JpegBackend::Cpu,
6780            },
6781        )
6782        .expect("encode 4:2:0 source jpeg");
6783        let output = MetalBatchTextureOutput::new_rgba8_tiles(&session, dimensions, 2)
6784            .expect("texture output");
6785        let inputs = [jpeg.data.as_slice(), jpeg.data.as_slice()];
6786        let (expected_rgb, _) = CpuDecoder::new(&jpeg.data)
6787            .expect("cpu decoder")
6788            .decode(PixelFormat::Rgb8)
6789            .expect("cpu decode");
6790        let expected_rgba = rgb_to_rgba_opaque(&expected_rgb);
6791
6792        compute::reset_jpeg_private_buffer_allocations_for_test();
6793        let tiles = decode_rgb8_batch_into_metal_textures_with_session(&inputs, &output, &session)
6794            .expect("decode into reusable textures");
6795
6796        assert_eq!(tiles.len(), 2);
6797        for (index, tile) in tiles.into_iter().enumerate() {
6798            let tile = tile.expect("texture tile");
6799            assert_eq!(tile.dimensions(), dimensions);
6800            assert_eq!(tile.pixel_format(), PixelFormat::Rgba8);
6801            assert!(std::ptr::eq(
6802                tile.texture(),
6803                output.texture(index).expect("output texture")
6804            ));
6805            assert_eq!(
6806                download_rgba8_texture(&session, tile.texture(), tile.dimensions()),
6807                expected_rgba
6808            );
6809        }
6810        assert_eq!(
6811            compute::jpeg_private_buffer_allocations_for_test(),
6812            0,
6813            "wide-row fused 4:2:0 texture batch decode should not allocate private Y/Cb/Cr staging planes"
6814        );
6815    }
6816
6817    #[cfg(target_os = "macos")]
6818    #[test]
6819    fn rgb8_multi_row_fast420_texture_batch_decode_fuses_directly_into_reusable_metal_textures() {
6820        let session = MetalBackendSession::system_default().expect("Metal backend session");
6821        let dimensions = (16, 32);
6822        let rgb = j2k_test_support::patterned_rgb8(dimensions.0, dimensions.1);
6823        let jpeg = encode_jpeg_baseline(
6824            JpegSamples::Rgb8 {
6825                data: &rgb,
6826                width: dimensions.0,
6827                height: dimensions.1,
6828            },
6829            JpegEncodeOptions {
6830                quality: 92,
6831                subsampling: JpegSubsampling::Ybr420,
6832                restart_interval: None,
6833                backend: JpegBackend::Cpu,
6834            },
6835        )
6836        .expect("encode 4:2:0 source jpeg");
6837        let output = MetalBatchTextureOutput::new_rgba8_tiles(&session, dimensions, 2)
6838            .expect("texture output");
6839        let inputs = [jpeg.data.as_slice(), jpeg.data.as_slice()];
6840        let (expected_rgb, _) = CpuDecoder::new(&jpeg.data)
6841            .expect("cpu decoder")
6842            .decode(PixelFormat::Rgb8)
6843            .expect("cpu decode");
6844        let expected_rgba = rgb_to_rgba_opaque(&expected_rgb);
6845
6846        compute::reset_jpeg_private_buffer_allocations_for_test();
6847        let tiles = decode_rgb8_batch_into_metal_textures_with_session(&inputs, &output, &session)
6848            .expect("decode into reusable textures");
6849
6850        assert_eq!(tiles.len(), 2);
6851        for (index, tile) in tiles.into_iter().enumerate() {
6852            let tile = tile.expect("texture tile");
6853            assert_eq!(tile.dimensions(), dimensions);
6854            assert_eq!(tile.pixel_format(), PixelFormat::Rgba8);
6855            assert!(std::ptr::eq(
6856                tile.texture(),
6857                output.texture(index).expect("output texture")
6858            ));
6859            assert_eq!(
6860                download_rgba8_texture(&session, tile.texture(), tile.dimensions()),
6861                expected_rgba
6862            );
6863        }
6864        assert_eq!(
6865            compute::jpeg_private_buffer_allocations_for_test(),
6866            0,
6867            "multi-row fused 4:2:0 texture batch decode should not allocate private Y/Cb/Cr staging planes"
6868        );
6869    }
6870
6871    #[cfg(target_os = "macos")]
6872    #[test]
6873    fn rgb8_multi_axis_fast420_texture_batch_decode_fuses_directly_into_reusable_metal_textures() {
6874        let session = MetalBackendSession::system_default().expect("Metal backend session");
6875        for dimensions in [(32, 32), (48, 48)] {
6876            let rgb = j2k_test_support::patterned_rgb8(dimensions.0, dimensions.1);
6877            let jpeg = encode_jpeg_baseline(
6878                JpegSamples::Rgb8 {
6879                    data: &rgb,
6880                    width: dimensions.0,
6881                    height: dimensions.1,
6882                },
6883                JpegEncodeOptions {
6884                    quality: 92,
6885                    subsampling: JpegSubsampling::Ybr420,
6886                    restart_interval: None,
6887                    backend: JpegBackend::Cpu,
6888                },
6889            )
6890            .expect("encode 4:2:0 source jpeg");
6891            let output = MetalBatchTextureOutput::new_rgba8_tiles(&session, dimensions, 2)
6892                .expect("texture output");
6893            let inputs = [jpeg.data.as_slice(), jpeg.data.as_slice()];
6894            let (expected_rgb, _) = CpuDecoder::new(&jpeg.data)
6895                .expect("cpu decoder")
6896                .decode(PixelFormat::Rgb8)
6897                .expect("cpu decode");
6898            let expected_rgba = rgb_to_rgba_opaque(&expected_rgb);
6899
6900            compute::reset_jpeg_private_buffer_allocations_for_test();
6901            let tiles =
6902                decode_rgb8_batch_into_metal_textures_with_session(&inputs, &output, &session)
6903                    .expect("decode into reusable textures");
6904
6905            assert_eq!(tiles.len(), 2);
6906            for (index, tile) in tiles.into_iter().enumerate() {
6907                let tile = tile.expect("texture tile");
6908                assert_eq!(tile.dimensions(), dimensions);
6909                assert_eq!(tile.pixel_format(), PixelFormat::Rgba8);
6910                assert!(std::ptr::eq(
6911                    tile.texture(),
6912                    output.texture(index).expect("output texture")
6913                ));
6914                assert_eq!(
6915                    download_rgba8_texture(&session, tile.texture(), tile.dimensions()),
6916                    expected_rgba
6917                );
6918            }
6919            assert_eq!(
6920                compute::jpeg_private_buffer_allocations_for_test(),
6921                0,
6922                "multi-axis fused 4:2:0 texture batch decode should not allocate private Y/Cb/Cr staging planes for {dimensions:?}"
6923            );
6924        }
6925    }
6926
6927    #[cfg(target_os = "macos")]
6928    #[test]
6929    fn rgb8_chunked_multi_axis_fast420_texture_batch_decode_fuses_directly_into_reusable_metal_textures(
6930    ) {
6931        let session = MetalBackendSession::system_default().expect("Metal backend session");
6932        let dimensions = (736, 720);
6933        let rgb = j2k_test_support::patterned_rgb8(dimensions.0, dimensions.1);
6934        let jpeg = encode_jpeg_baseline(
6935            JpegSamples::Rgb8 {
6936                data: &rgb,
6937                width: dimensions.0,
6938                height: dimensions.1,
6939            },
6940            JpegEncodeOptions {
6941                quality: 90,
6942                subsampling: JpegSubsampling::Ybr420,
6943                restart_interval: None,
6944                backend: JpegBackend::Cpu,
6945            },
6946        )
6947        .expect("encode chunked 4:2:0 source jpeg");
6948        let output = MetalBatchTextureOutput::new_rgba8_tiles(&session, dimensions, 2)
6949            .expect("texture output");
6950        let inputs = [jpeg.data.as_slice(), jpeg.data.as_slice()];
6951        let (expected_rgb, _) = CpuDecoder::new(&jpeg.data)
6952            .expect("cpu decoder")
6953            .decode(PixelFormat::Rgb8)
6954            .expect("cpu decode");
6955        let expected_rgba = rgb_to_rgba_opaque(&expected_rgb);
6956
6957        compute::reset_jpeg_private_buffer_allocations_for_test();
6958        let tiles = decode_rgb8_batch_into_metal_textures_with_session(&inputs, &output, &session)
6959            .expect("decode into reusable textures");
6960
6961        assert_eq!(tiles.len(), 2);
6962        for (index, tile) in tiles.into_iter().enumerate() {
6963            let tile = tile.expect("texture tile");
6964            assert_eq!(tile.dimensions(), dimensions);
6965            assert_eq!(tile.pixel_format(), PixelFormat::Rgba8);
6966            assert!(std::ptr::eq(
6967                tile.texture(),
6968                output.texture(index).expect("output texture")
6969            ));
6970            assert_eq!(
6971                download_rgba8_texture(&session, tile.texture(), tile.dimensions()),
6972                expected_rgba
6973            );
6974        }
6975        assert_eq!(
6976            compute::jpeg_private_buffer_allocations_for_test(),
6977            0,
6978            "chunked multi-axis fused 4:2:0 texture batch decode should not allocate private Y/Cb/Cr staging planes"
6979        );
6980    }
6981
6982    #[cfg(target_os = "macos")]
6983    #[test]
6984    fn rgb8_restart_fast420_texture_batch_decode_fuses_directly_into_reusable_metal_textures() {
6985        let session = MetalBackendSession::system_default().expect("Metal backend session");
6986        let dimensions = (48, 48);
6987        let rgb = j2k_test_support::patterned_rgb8(dimensions.0, dimensions.1);
6988        let jpeg = encode_jpeg_baseline(
6989            JpegSamples::Rgb8 {
6990                data: &rgb,
6991                width: dimensions.0,
6992                height: dimensions.1,
6993            },
6994            JpegEncodeOptions {
6995                quality: 90,
6996                subsampling: JpegSubsampling::Ybr420,
6997                restart_interval: Some(2),
6998                backend: JpegBackend::Cpu,
6999            },
7000        )
7001        .expect("encode restart 4:2:0 source jpeg");
7002        let output = MetalBatchTextureOutput::new_rgba8_tiles(&session, dimensions, 2)
7003            .expect("texture output");
7004        let inputs = [jpeg.data.as_slice(), jpeg.data.as_slice()];
7005        let (expected_rgb, _) = CpuDecoder::new(&jpeg.data)
7006            .expect("cpu decoder")
7007            .decode(PixelFormat::Rgb8)
7008            .expect("cpu decode");
7009        let expected_rgba = rgb_to_rgba_opaque(&expected_rgb);
7010
7011        compute::reset_jpeg_private_buffer_allocations_for_test();
7012        let tiles = decode_rgb8_batch_into_metal_textures_with_session(&inputs, &output, &session)
7013            .expect("decode into reusable textures");
7014
7015        assert_eq!(tiles.len(), 2);
7016        for (index, tile) in tiles.into_iter().enumerate() {
7017            let tile = tile.expect("texture tile");
7018            assert_eq!(tile.dimensions(), dimensions);
7019            assert_eq!(tile.pixel_format(), PixelFormat::Rgba8);
7020            assert!(std::ptr::eq(
7021                tile.texture(),
7022                output.texture(index).expect("output texture")
7023            ));
7024            assert_eq!(
7025                download_rgba8_texture(&session, tile.texture(), tile.dimensions()),
7026                expected_rgba
7027            );
7028        }
7029        assert_eq!(
7030            compute::jpeg_private_buffer_allocations_for_test(),
7031            0,
7032            "restart fused 4:2:0 texture batch decode should not allocate private Y/Cb/Cr staging planes"
7033        );
7034    }
7035
7036    #[cfg(target_os = "macos")]
7037    #[test]
7038    fn rgb8_distinct_restart_fast420_texture_batch_decode_fuses_directly_into_reusable_metal_textures(
7039    ) {
7040        let session = MetalBackendSession::system_default().expect("Metal backend session");
7041        let dimensions = (128, 128);
7042        let rgb_a = j2k_test_support::patterned_rgb8(dimensions.0, dimensions.1);
7043        let mut rgb_b = j2k_test_support::patterned_rgb8(dimensions.0, dimensions.1);
7044        for (index, pixel) in rgb_b.chunks_exact_mut(3).enumerate() {
7045            let delta = patterned_index_byte(index)
7046                .wrapping_mul(17)
7047                .wrapping_add(31);
7048            pixel[0] = pixel[0].wrapping_add(delta);
7049            pixel[1] = pixel[1].wrapping_sub(delta.rotate_left(1));
7050            pixel[2] ^= delta.rotate_right(1);
7051        }
7052        assert_ne!(rgb_a, rgb_b);
7053
7054        let jpeg_a = encode_jpeg_baseline(
7055            JpegSamples::Rgb8 {
7056                data: &rgb_a,
7057                width: dimensions.0,
7058                height: dimensions.1,
7059            },
7060            JpegEncodeOptions {
7061                quality: 90,
7062                subsampling: JpegSubsampling::Ybr420,
7063                restart_interval: Some(4),
7064                backend: JpegBackend::Cpu,
7065            },
7066        )
7067        .expect("encode first restart 4:2:0 source jpeg");
7068        let jpeg_b = encode_jpeg_baseline(
7069            JpegSamples::Rgb8 {
7070                data: &rgb_b,
7071                width: dimensions.0,
7072                height: dimensions.1,
7073            },
7074            JpegEncodeOptions {
7075                quality: 90,
7076                subsampling: JpegSubsampling::Ybr420,
7077                restart_interval: Some(4),
7078                backend: JpegBackend::Cpu,
7079            },
7080        )
7081        .expect("encode second restart 4:2:0 source jpeg");
7082        assert_ne!(jpeg_a.data, jpeg_b.data);
7083
7084        let output = MetalBatchTextureOutput::new_rgba8_tiles(&session, dimensions, 2)
7085            .expect("texture output");
7086        let inputs = [jpeg_a.data.as_slice(), jpeg_b.data.as_slice()];
7087        let (expected_rgb_a, _) = CpuDecoder::new(&jpeg_a.data)
7088            .expect("first cpu decoder")
7089            .decode(PixelFormat::Rgb8)
7090            .expect("first cpu decode");
7091        let (expected_rgb_b, _) = CpuDecoder::new(&jpeg_b.data)
7092            .expect("second cpu decoder")
7093            .decode(PixelFormat::Rgb8)
7094            .expect("second cpu decode");
7095        let expected_tiles = [
7096            rgb_to_rgba_opaque(&expected_rgb_a),
7097            rgb_to_rgba_opaque(&expected_rgb_b),
7098        ];
7099        assert_ne!(expected_tiles[0], expected_tiles[1]);
7100
7101        compute::reset_jpeg_private_buffer_allocations_for_test();
7102        let tiles = decode_rgb8_batch_into_metal_textures_with_session(&inputs, &output, &session)
7103            .expect("decode distinct restart tiles into reusable textures");
7104
7105        assert_eq!(tiles.len(), 2);
7106        for (index, tile) in tiles.into_iter().enumerate() {
7107            let tile = tile.expect("texture tile");
7108            assert_eq!(tile.dimensions(), dimensions);
7109            assert_eq!(tile.pixel_format(), PixelFormat::Rgba8);
7110            assert!(std::ptr::eq(
7111                tile.texture(),
7112                output.texture(index).expect("output texture")
7113            ));
7114            let actual_rgba = download_rgba8_texture(&session, tile.texture(), tile.dimensions());
7115            assert_eq!(actual_rgba.as_slice(), expected_tiles[index].as_slice());
7116        }
7117        assert_eq!(
7118            compute::jpeg_private_buffer_allocations_for_test(),
7119            0,
7120            "distinct restart fused 4:2:0 texture batch decode should not allocate private Y/Cb/Cr staging planes"
7121        );
7122    }
7123
7124    #[cfg(target_os = "macos")]
7125    #[test]
7126    fn rgb8_table_mixed_restart_fast420_texture_batch_groups_resident_dispatches() {
7127        let session = MetalBackendSession::system_default().expect("Metal backend session");
7128        let dimensions = (128, 128);
7129        let rgb_a = j2k_test_support::patterned_rgb8(dimensions.0, dimensions.1);
7130        let mut rgb_b = j2k_test_support::patterned_rgb8(dimensions.0, dimensions.1);
7131        let mut rgb_c = j2k_test_support::patterned_rgb8(dimensions.0, dimensions.1);
7132        for (index, pixel) in rgb_b.chunks_exact_mut(3).enumerate() {
7133            let delta = patterned_index_byte(index).wrapping_mul(29).wrapping_add(7);
7134            pixel[0] ^= delta;
7135            pixel[1] = pixel[1].wrapping_add(delta.rotate_left(2));
7136            pixel[2] = pixel[2].wrapping_sub(delta.rotate_right(2));
7137        }
7138        for (index, pixel) in rgb_c.chunks_exact_mut(3).enumerate() {
7139            let delta = patterned_index_byte(index)
7140                .wrapping_mul(13)
7141                .wrapping_add(41);
7142            pixel[0] = pixel[0].wrapping_sub(delta.rotate_left(1));
7143            pixel[1] ^= delta.rotate_right(3);
7144            pixel[2] = pixel[2].wrapping_add(delta);
7145        }
7146
7147        let jpeg_a = encode_jpeg_baseline(
7148            JpegSamples::Rgb8 {
7149                data: &rgb_a,
7150                width: dimensions.0,
7151                height: dimensions.1,
7152            },
7153            JpegEncodeOptions {
7154                quality: 90,
7155                subsampling: JpegSubsampling::Ybr420,
7156                restart_interval: Some(4),
7157                backend: JpegBackend::Cpu,
7158            },
7159        )
7160        .expect("encode first table group jpeg");
7161        let jpeg_b = encode_jpeg_baseline(
7162            JpegSamples::Rgb8 {
7163                data: &rgb_b,
7164                width: dimensions.0,
7165                height: dimensions.1,
7166            },
7167            JpegEncodeOptions {
7168                quality: 74,
7169                subsampling: JpegSubsampling::Ybr420,
7170                restart_interval: Some(4),
7171                backend: JpegBackend::Cpu,
7172            },
7173        )
7174        .expect("encode second table group jpeg");
7175        let jpeg_c = encode_jpeg_baseline(
7176            JpegSamples::Rgb8 {
7177                data: &rgb_c,
7178                width: dimensions.0,
7179                height: dimensions.1,
7180            },
7181            JpegEncodeOptions {
7182                quality: 90,
7183                subsampling: JpegSubsampling::Ybr420,
7184                restart_interval: Some(4),
7185                backend: JpegBackend::Cpu,
7186            },
7187        )
7188        .expect("encode third table group jpeg");
7189        let packet_a = build_fast420_packet(&jpeg_a.data).expect("first fast420 packet");
7190        let packet_b = build_fast420_packet(&jpeg_b.data).expect("second fast420 packet");
7191        let packet_c = build_fast420_packet(&jpeg_c.data).expect("third fast420 packet");
7192        assert_eq!(packet_a.y_quant, packet_c.y_quant);
7193        assert_eq!(packet_a.cb_quant, packet_c.cb_quant);
7194        assert_eq!(packet_a.cr_quant, packet_c.cr_quant);
7195        assert_eq!(packet_a.y_dc_table, packet_c.y_dc_table);
7196        assert_eq!(packet_a.y_ac_table, packet_c.y_ac_table);
7197        assert_eq!(
7198            packet_a.entropy_checkpoints.len(),
7199            packet_c.entropy_checkpoints.len()
7200        );
7201        assert_ne!(packet_a.y_quant, packet_b.y_quant);
7202
7203        let output = MetalBatchTextureOutput::new_rgba8_tiles(&session, dimensions, 3)
7204            .expect("texture output");
7205        let inputs = [
7206            jpeg_a.data.as_slice(),
7207            jpeg_b.data.as_slice(),
7208            jpeg_c.data.as_slice(),
7209        ];
7210        let (expected_rgb_a, _) = CpuDecoder::new(&jpeg_a.data)
7211            .expect("first cpu decoder")
7212            .decode(PixelFormat::Rgb8)
7213            .expect("first cpu decode");
7214        let (expected_rgb_b, _) = CpuDecoder::new(&jpeg_b.data)
7215            .expect("second cpu decoder")
7216            .decode(PixelFormat::Rgb8)
7217            .expect("second cpu decode");
7218        let (expected_rgb_c, _) = CpuDecoder::new(&jpeg_c.data)
7219            .expect("third cpu decoder")
7220            .decode(PixelFormat::Rgb8)
7221            .expect("third cpu decode");
7222        let expected_tiles = [
7223            rgb_to_rgba_opaque(&expected_rgb_a),
7224            rgb_to_rgba_opaque(&expected_rgb_b),
7225            rgb_to_rgba_opaque(&expected_rgb_c),
7226        ];
7227        assert_ne!(expected_tiles[0], expected_tiles[1]);
7228        assert_ne!(expected_tiles[0], expected_tiles[2]);
7229        assert_ne!(expected_tiles[1], expected_tiles[2]);
7230
7231        compute::reset_jpeg_private_buffer_allocations_for_test();
7232        let tiles = decode_rgb8_batch_into_metal_textures_with_session(&inputs, &output, &session)
7233            .expect("decode table-mixed restart tiles into reusable textures");
7234
7235        assert_eq!(tiles.len(), 3);
7236        for (index, tile) in tiles.into_iter().enumerate() {
7237            let tile = tile.expect("texture tile");
7238            assert_eq!(tile.dimensions(), dimensions);
7239            assert_eq!(tile.pixel_format(), PixelFormat::Rgba8);
7240            assert!(std::ptr::eq(
7241                tile.texture(),
7242                output.texture(index).expect("output texture")
7243            ));
7244            let actual_rgba = download_rgba8_texture(&session, tile.texture(), tile.dimensions());
7245            assert_eq!(actual_rgba.as_slice(), expected_tiles[index].as_slice());
7246        }
7247        assert_eq!(
7248            compute::jpeg_private_buffer_allocations_for_test(),
7249            0,
7250            "table-mixed resident 4:2:0 texture dispatches should not allocate private Y/Cb/Cr staging planes"
7251        );
7252    }
7253
7254    #[cfg(target_os = "macos")]
7255    #[test]
7256    fn jpeg_device_decode_uses_private_internal_planes() {
7257        let session = MetalBackendSession::system_default().expect("Metal backend session");
7258        let mut decoder = Decoder::new(BASELINE_420).expect("decoder");
7259
7260        compute::reset_jpeg_private_buffer_allocations_for_test();
7261        let surface = decoder
7262            .decode_to_device_with_session(PixelFormat::Rgb8, &session)
7263            .expect("resident JPEG Metal decode");
7264        assert_eq!(surface.residency(), SurfaceResidency::MetalResidentDecode);
7265        assert!(
7266            compute::jpeg_private_buffer_allocations_for_test() > 0,
7267            "resident JPEG Metal decode should use Private internal planes"
7268        );
7269        let _ = surface.as_bytes();
7270    }
7271
7272    #[cfg(target_os = "macos")]
7273    #[test]
7274    fn jpeg_private_rgb8_tile_uses_private_output_buffer() {
7275        let session = MetalBackendSession::system_default().expect("Metal backend session");
7276        let mut decoder = Decoder::new(BASELINE_420).expect("decoder");
7277
7278        let tile = decoder
7279            .decode_private_rgb8_tile_with_session(&session)
7280            .expect("resident private JPEG Metal decode");
7281
7282        assert_eq!(tile.dimensions, (16, 16));
7283        assert_eq!(tile.pixel_format, PixelFormat::Rgb8);
7284        assert_eq!(tile.pitch_bytes, 16 * PixelFormat::Rgb8.bytes_per_pixel());
7285        assert_eq!(tile.byte_offset, 0);
7286        assert_eq!(tile.buffer.storage_mode(), metal::MTLStorageMode::Private);
7287        assert!(tile.status_buffer.length() > 0);
7288    }
7289
7290    #[cfg(target_os = "macos")]
7291    #[test]
7292    fn jpeg_gray_region_decode_uses_private_internal_planes() {
7293        let roi = Rect {
7294            x: 4,
7295            y: 4,
7296            w: 8,
7297            h: 8,
7298        };
7299        let mut expected_decoder = Decoder::new(BASELINE_420).expect("expected decoder");
7300        let mut expected = vec![0; roi.w as usize * roi.h as usize];
7301        expected_decoder
7302            .decode_region_into(
7303                &mut CpuScratchPool::new(),
7304                &mut expected,
7305                roi.w as usize,
7306                PixelFormat::Gray8,
7307                roi,
7308            )
7309            .expect("expected CPU region decode");
7310
7311        let mut decoder = Decoder::new(BASELINE_420).expect("decoder");
7312        compute::reset_jpeg_private_buffer_allocations_for_test();
7313        let surface = decoder
7314            .decode_region_to_device(PixelFormat::Gray8, roi, BackendRequest::Metal)
7315            .expect("resident JPEG Metal region decode");
7316        assert_eq!(surface.residency(), SurfaceResidency::MetalResidentDecode);
7317        assert!(
7318            compute::jpeg_private_buffer_allocations_for_test() >= 3,
7319            "resident Gray8 region decode should keep decoded Y/Cb/Cr planes Private"
7320        );
7321        assert_eq!(surface.as_bytes(), expected.as_slice());
7322    }
7323
7324    #[cfg(target_os = "macos")]
7325    #[test]
7326    fn uploaded_metal_surface_is_marked_cpu_staged() {
7327        let surface = upload_surface(
7328            vec![1, 2, 3],
7329            (1, 1),
7330            PixelFormat::Rgb8,
7331            BackendRequest::Metal,
7332        )
7333        .expect("CPU staged Metal upload");
7334
7335        assert_eq!(surface.residency(), SurfaceResidency::CpuStagedMetalUpload);
7336    }
7337
7338    #[test]
7339    fn auto_route_prefers_cpu_host_for_region_scaled_even_with_restart_packets() {
7340        let decoder = CpuDecoder::new(BASELINE_420_RESTART).expect("restart decoder");
7341        let packet = build_fast420_packet(BASELINE_420_RESTART).expect("restart packet");
7342
7343        assert_eq!(
7344            choose_route(
7345                &decoder,
7346                BackendRequest::Auto,
7347                PixelFormat::Rgb8,
7348                batch::BatchOp::RegionScaled {
7349                    roi: Rect {
7350                        x: 0,
7351                        y: 0,
7352                        w: 16,
7353                        h: 16,
7354                    },
7355                    scale: Downscale::Quarter,
7356                },
7357                None,
7358                None,
7359                Some(&packet),
7360            ),
7361            routing::RouteDecision::CpuHost
7362        );
7363    }
7364
7365    #[cfg(not(target_os = "macos"))]
7366    #[test]
7367    fn session_decode_rejects_unsupported_shape_before_host_unavailability() {
7368        let mut decoder = Decoder::new(GRAYSCALE).expect("decoder");
7369        let session = MetalBackendSession::default();
7370
7371        assert!(matches!(
7372            decoder.decode_to_device_with_session(PixelFormat::Gray8, &session),
7373            Err(Error::UnsupportedMetalRequest { .. })
7374        ));
7375    }
7376}