Skip to main content

j2k_transcode_metal/
lib.rs

1// SPDX-License-Identifier: Apache-2.0
2
3//! Metal acceleration for coefficient-domain JPEG to HTJ2K transcode stages.
4//!
5//! The supported targets are direct DCT-grid to one-level 5/3 and 9/7 wavelet
6//! projections used by `j2k-transcode`'s HTJ2K paths. CPU scalar code
7//! remains the oracle and fallback.
8
9#[cfg(target_os = "macos")]
10mod metal;
11
12#[doc(hidden)]
13pub mod weights;
14
15#[cfg(target_os = "macos")]
16pub use metal::MetalTranscodeSession;
17
18use core::fmt;
19
20use j2k_transcode::accelerator::{
21    DctGridToDwt53Job, DctGridToDwt97Job, DctGridToHtj2k97CodeBlockJob,
22    DctGridToReversibleDwt53Job, DctToWaveletStageAccelerator, Dwt97BatchStageTimings,
23    Htj2k97CodeBlockOptions, PrequantizedHtj2k97Component, ReversibleDwt53FirstLevel,
24    TranscodeStageError,
25};
26use j2k_transcode::dct53_2d::Dwt53TwoDimensional;
27use j2k_transcode::dct97_2d::Dwt97TwoDimensional;
28
29/// Stable message returned when Metal is unavailable.
30pub const METAL_UNAVAILABLE: &str = "Metal is unavailable on this host";
31
32const DEFAULT_AUTO_MIN_SAMPLES: usize = 224 * 224;
33const DEFAULT_AUTO_DWT97_MIN_SAMPLES: usize = usize::MAX;
34const DEFAULT_AUTO_REVERSIBLE_MIN_SAMPLES: usize = usize::MAX;
35const DEFAULT_AUTO_REVERSIBLE_BATCH_MIN_JOBS: usize = 32;
36const DEFAULT_AUTO_REVERSIBLE_BATCH_MIN_SAMPLES: usize = 224 * 224 * 32;
37const DEFAULT_AUTO_DWT97_BATCH_MIN_JOBS: usize = 32;
38const DEFAULT_AUTO_DWT97_BATCH_MIN_SAMPLES: usize = 224 * 224 * 32;
39const MAX_AUTO_DWT97_STAGED_BATCH_AXIS: usize = 1024;
40
41/// Error returned by the Metal transcode accelerator.
42#[derive(Debug, Clone, Copy, PartialEq, Eq)]
43pub enum MetalTranscodeError {
44    /// Metal is unavailable on this host or target.
45    MetalUnavailable,
46    /// The request is outside the current Metal implementation.
47    UnsupportedJob(&'static str),
48    /// Metal runtime creation or device setup failed.
49    Runtime(&'static str),
50    /// Metal runtime or kernel execution failed.
51    Kernel(&'static str),
52}
53
54impl fmt::Display for MetalTranscodeError {
55    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
56        match self {
57            Self::MetalUnavailable => f.write_str(METAL_UNAVAILABLE),
58            Self::UnsupportedJob(reason) | Self::Runtime(reason) | Self::Kernel(reason) => {
59                f.write_str(reason)
60            }
61        }
62    }
63}
64
65impl From<MetalTranscodeError> for TranscodeStageError {
66    fn from(error: MetalTranscodeError) -> Self {
67        match error {
68            MetalTranscodeError::MetalUnavailable => Self::DeviceUnavailable,
69            MetalTranscodeError::UnsupportedJob(reason) => Self::Unsupported(reason),
70            MetalTranscodeError::Runtime(reason) | MetalTranscodeError::Kernel(reason) => {
71                Self::Backend(reason.to_string())
72            }
73        }
74    }
75}
76
77impl std::error::Error for MetalTranscodeError {}
78
79#[cfg(not(target_os = "macos"))]
80#[derive(Clone, Copy, Debug, Default)]
81/// Placeholder Metal transcode session for hosts without Metal support.
82pub struct MetalTranscodeSession {
83    _private: (),
84}
85
86#[cfg(not(target_os = "macos"))]
87impl MetalTranscodeSession {
88    /// Return `MetalUnavailable` on hosts without Metal support.
89    pub const fn system_default() -> Result<Self, MetalTranscodeError> {
90        Err(MetalTranscodeError::MetalUnavailable)
91    }
92}
93
94/// Optional Metal accelerator for `j2k-transcode` transform stages.
95#[derive(Debug, Clone)]
96pub struct MetalDctToWaveletStageAccelerator {
97    mode: MetalDispatchMode,
98    min_auto_samples: usize,
99    min_auto_dwt97_samples: usize,
100    min_auto_reversible_samples: usize,
101    min_auto_reversible_batch_jobs: usize,
102    min_auto_reversible_batch_samples: usize,
103    reversible_dwt53_attempts: usize,
104    reversible_dwt53_dispatches: usize,
105    reversible_dwt53_batch_attempts: usize,
106    reversible_dwt53_batch_dispatches: usize,
107    dwt53_attempts: usize,
108    dwt53_dispatches: usize,
109    dwt97_attempts: usize,
110    dwt97_dispatches: usize,
111    dwt97_batch_attempts: usize,
112    dwt97_batch_dispatches: usize,
113    htj2k97_codeblock_batch_attempts: usize,
114    htj2k97_codeblock_batch_dispatches: usize,
115    last_dwt97_batch_stage_timings: Option<Dwt97BatchStageTimings>,
116    min_auto_dwt97_batch_jobs: usize,
117    min_auto_dwt97_batch_samples: usize,
118    #[cfg(target_os = "macos")]
119    session: Option<MetalTranscodeSession>,
120}
121
122#[derive(Debug, Clone, Copy, PartialEq, Eq)]
123enum MetalDispatchMode {
124    Explicit,
125    Auto,
126}
127
128impl MetalDctToWaveletStageAccelerator {
129    /// Create an accelerator that treats unsupported Metal dispatch as an
130    /// error.
131    #[must_use]
132    pub const fn new_explicit() -> Self {
133        Self {
134            mode: MetalDispatchMode::Explicit,
135            min_auto_samples: 0,
136            min_auto_dwt97_samples: 0,
137            min_auto_reversible_samples: 0,
138            min_auto_reversible_batch_jobs: 0,
139            min_auto_reversible_batch_samples: 0,
140            reversible_dwt53_attempts: 0,
141            reversible_dwt53_dispatches: 0,
142            reversible_dwt53_batch_attempts: 0,
143            reversible_dwt53_batch_dispatches: 0,
144            dwt53_attempts: 0,
145            dwt53_dispatches: 0,
146            dwt97_attempts: 0,
147            dwt97_dispatches: 0,
148            dwt97_batch_attempts: 0,
149            dwt97_batch_dispatches: 0,
150            htj2k97_codeblock_batch_attempts: 0,
151            htj2k97_codeblock_batch_dispatches: 0,
152            last_dwt97_batch_stage_timings: None,
153            min_auto_dwt97_batch_jobs: 0,
154            min_auto_dwt97_batch_samples: 0,
155            #[cfg(target_os = "macos")]
156            session: None,
157        }
158    }
159
160    /// Create an accelerator that falls back to scalar CPU for small or
161    /// unsupported jobs.
162    #[must_use]
163    pub const fn for_auto() -> Self {
164        Self {
165            mode: MetalDispatchMode::Auto,
166            min_auto_samples: DEFAULT_AUTO_MIN_SAMPLES,
167            min_auto_dwt97_samples: DEFAULT_AUTO_DWT97_MIN_SAMPLES,
168            min_auto_reversible_samples: DEFAULT_AUTO_REVERSIBLE_MIN_SAMPLES,
169            min_auto_reversible_batch_jobs: DEFAULT_AUTO_REVERSIBLE_BATCH_MIN_JOBS,
170            min_auto_reversible_batch_samples: DEFAULT_AUTO_REVERSIBLE_BATCH_MIN_SAMPLES,
171            reversible_dwt53_attempts: 0,
172            reversible_dwt53_dispatches: 0,
173            reversible_dwt53_batch_attempts: 0,
174            reversible_dwt53_batch_dispatches: 0,
175            dwt53_attempts: 0,
176            dwt53_dispatches: 0,
177            dwt97_attempts: 0,
178            dwt97_dispatches: 0,
179            dwt97_batch_attempts: 0,
180            dwt97_batch_dispatches: 0,
181            htj2k97_codeblock_batch_attempts: 0,
182            htj2k97_codeblock_batch_dispatches: 0,
183            last_dwt97_batch_stage_timings: None,
184            min_auto_dwt97_batch_jobs: DEFAULT_AUTO_DWT97_BATCH_MIN_JOBS,
185            min_auto_dwt97_batch_samples: DEFAULT_AUTO_DWT97_BATCH_MIN_SAMPLES,
186            #[cfg(target_os = "macos")]
187            session: None,
188        }
189    }
190
191    /// Create an explicit-dispatch accelerator bound to a caller-owned Metal session.
192    #[cfg(target_os = "macos")]
193    #[must_use]
194    pub fn new_explicit_with_session(session: MetalTranscodeSession) -> Self {
195        Self::new_explicit().with_session(session)
196    }
197
198    /// Create an Auto-mode accelerator bound to a caller-owned Metal session.
199    #[cfg(target_os = "macos")]
200    #[must_use]
201    pub fn for_auto_with_session(session: MetalTranscodeSession) -> Self {
202        Self::for_auto().with_session(session)
203    }
204
205    /// Create an explicit-dispatch accelerator bound to an existing Metal device.
206    #[cfg(target_os = "macos")]
207    #[must_use]
208    pub fn new_explicit_with_device(device: ::metal::Device) -> Self {
209        Self::new_explicit_with_session(MetalTranscodeSession::new(device))
210    }
211
212    /// Create an Auto-mode accelerator bound to an existing Metal device.
213    #[cfg(target_os = "macos")]
214    #[must_use]
215    pub fn for_auto_with_device(device: ::metal::Device) -> Self {
216        Self::for_auto_with_session(MetalTranscodeSession::new(device))
217    }
218
219    /// Bind this accelerator to a caller-owned Metal session.
220    #[cfg(target_os = "macos")]
221    #[must_use]
222    pub fn with_session(mut self, session: MetalTranscodeSession) -> Self {
223        self.session = Some(session);
224        self
225    }
226
227    /// Bind this accelerator to an existing Metal device.
228    #[cfg(target_os = "macos")]
229    #[must_use]
230    pub fn with_device(self, device: ::metal::Device) -> Self {
231        self.with_session(MetalTranscodeSession::new(device))
232    }
233
234    #[cfg(target_os = "macos")]
235    fn metal_session(&mut self) -> &mut MetalTranscodeSession {
236        self.session
237            .get_or_insert_with(MetalTranscodeSession::default)
238    }
239
240    /// Override the minimum component sample count used before Auto mode
241    /// dispatches non-reversible projection jobs to Metal.
242    #[must_use]
243    pub const fn with_auto_min_samples(mut self, min_samples: usize) -> Self {
244        self.min_auto_samples = min_samples;
245        self.min_auto_dwt97_samples = min_samples;
246        self
247    }
248
249    /// Override the minimum component sample count used before Auto mode
250    /// dispatches 9/7 transform jobs to Metal.
251    #[must_use]
252    pub const fn with_auto_dwt97_min_samples(mut self, min_samples: usize) -> Self {
253        self.min_auto_dwt97_samples = min_samples;
254        self
255    }
256
257    /// Override the 9/7 batch thresholds used before Auto mode dispatches a
258    /// same-geometry batch to Metal.
259    #[must_use]
260    pub const fn with_auto_dwt97_batch_thresholds(
261        mut self,
262        min_jobs: usize,
263        min_samples: usize,
264    ) -> Self {
265        self.min_auto_dwt97_batch_jobs = min_jobs;
266        self.min_auto_dwt97_batch_samples = min_samples;
267        self
268    }
269
270    /// Override the minimum component sample count used before Auto mode
271    /// dispatches single reversible 5/3 jobs to Metal.
272    #[must_use]
273    pub const fn with_auto_reversible_min_samples(mut self, min_samples: usize) -> Self {
274        self.min_auto_reversible_samples = min_samples;
275        self
276    }
277
278    /// Override the reversible 5/3 batch thresholds used before Auto mode
279    /// dispatches a same-geometry batch to Metal.
280    #[must_use]
281    pub const fn with_auto_reversible_batch_thresholds(
282        mut self,
283        min_jobs: usize,
284        min_samples: usize,
285    ) -> Self {
286        self.min_auto_reversible_batch_jobs = min_jobs;
287        self.min_auto_reversible_batch_samples = min_samples;
288        self
289    }
290
291    /// Number of reversible integer 5/3 jobs offered to this accelerator.
292    #[must_use]
293    pub const fn reversible_dwt53_attempts(&self) -> usize {
294        self.reversible_dwt53_attempts
295    }
296
297    /// Number of reversible integer 5/3 jobs handled by Metal.
298    #[must_use]
299    pub const fn reversible_dwt53_dispatches(&self) -> usize {
300        self.reversible_dwt53_dispatches
301    }
302
303    /// Number of reversible integer 5/3 batches offered to this accelerator.
304    #[must_use]
305    pub const fn reversible_dwt53_batch_attempts(&self) -> usize {
306        self.reversible_dwt53_batch_attempts
307    }
308
309    /// Number of reversible integer 5/3 batches handled by Metal.
310    #[must_use]
311    pub const fn reversible_dwt53_batch_dispatches(&self) -> usize {
312        self.reversible_dwt53_batch_dispatches
313    }
314
315    /// Number of 5/3 projection jobs offered to this accelerator.
316    #[must_use]
317    pub const fn dwt53_attempts(&self) -> usize {
318        self.dwt53_attempts
319    }
320
321    /// Number of 5/3 projection jobs handled by Metal.
322    #[must_use]
323    pub const fn dwt53_dispatches(&self) -> usize {
324        self.dwt53_dispatches
325    }
326
327    /// Number of 9/7 transform jobs offered to this accelerator.
328    #[must_use]
329    pub const fn dwt97_attempts(&self) -> usize {
330        self.dwt97_attempts
331    }
332
333    /// Number of 9/7 transform jobs handled by Metal.
334    #[must_use]
335    pub const fn dwt97_dispatches(&self) -> usize {
336        self.dwt97_dispatches
337    }
338
339    /// Number of 9/7 transform batches offered to this accelerator.
340    #[must_use]
341    pub const fn dwt97_batch_attempts(&self) -> usize {
342        self.dwt97_batch_attempts
343    }
344
345    /// Number of 9/7 transform batches handled by Metal.
346    #[must_use]
347    pub const fn dwt97_batch_dispatches(&self) -> usize {
348        self.dwt97_batch_dispatches
349    }
350
351    /// Number of 9/7 code-block-ready batches offered to this accelerator.
352    #[must_use]
353    pub const fn htj2k97_codeblock_batch_attempts(&self) -> usize {
354        self.htj2k97_codeblock_batch_attempts
355    }
356
357    /// Number of 9/7 code-block-ready batches handled by Metal.
358    #[must_use]
359    pub const fn htj2k97_codeblock_batch_dispatches(&self) -> usize {
360        self.htj2k97_codeblock_batch_dispatches
361    }
362
363    /// Backend stage timings for the most recent 9/7 batch dispatch.
364    #[must_use]
365    pub const fn last_dwt97_batch_stage_timings(&self) -> Option<Dwt97BatchStageTimings> {
366        self.last_dwt97_batch_stage_timings
367    }
368
369    /// Dispatch a same-geometry batch of reversible integer 5/3 DCT-grid
370    /// projection jobs. This is an experimental Metal-specific extension used
371    /// for WSI tile-component queues; scalar/Rayon remains the portable oracle.
372    pub fn dct_grid_to_reversible_dwt53_batch(
373        &mut self,
374        jobs: &[DctGridToReversibleDwt53Job<'_>],
375    ) -> Result<Option<Vec<ReversibleDwt53FirstLevel>>, TranscodeStageError> {
376        self.dispatch_reversible_dwt53_batch(jobs)
377    }
378
379    fn dispatch_reversible_dwt53_batch(
380        &mut self,
381        jobs: &[DctGridToReversibleDwt53Job<'_>],
382    ) -> Result<Option<Vec<ReversibleDwt53FirstLevel>>, TranscodeStageError> {
383        self.reversible_dwt53_batch_attempts =
384            self.reversible_dwt53_batch_attempts.saturating_add(1);
385
386        if jobs.is_empty() {
387            return Ok(Some(Vec::new()));
388        }
389
390        let total_samples = jobs.iter().fold(0usize, |total, job| {
391            total.saturating_add(job.width.saturating_mul(job.height))
392        });
393        // Auto declines with `Ok(None)` (small batches, unavailable Metal,
394        // unsupported jobs) so the caller runs its scalar fallback — the same
395        // contract as the float 5/3 path and the CUDA accelerator, instead of
396        // silently computing a Rayon fallback inside the backend.
397        if self.mode == MetalDispatchMode::Auto
398            && (jobs.len() < self.min_auto_reversible_batch_jobs
399                || total_samples < self.min_auto_reversible_batch_samples)
400        {
401            return Ok(None);
402        }
403
404        #[cfg(not(target_os = "macos"))]
405        {
406            match self.mode {
407                MetalDispatchMode::Explicit => Err(TranscodeStageError::DeviceUnavailable),
408                MetalDispatchMode::Auto => Ok(None),
409            }
410        }
411
412        #[cfg(target_os = "macos")]
413        {
414            match metal::dispatch_dct_grid_to_reversible_dwt53_batch(self.metal_session(), jobs) {
415                Ok(output) => {
416                    self.reversible_dwt53_batch_dispatches =
417                        self.reversible_dwt53_batch_dispatches.saturating_add(1);
418                    Ok(Some(output))
419                }
420                Err(
421                    MetalTranscodeError::MetalUnavailable | MetalTranscodeError::UnsupportedJob(_),
422                ) if self.mode == MetalDispatchMode::Auto => Ok(None),
423                Err(error) => Err(error.into()),
424            }
425        }
426    }
427}
428
429impl Default for MetalDctToWaveletStageAccelerator {
430    fn default() -> Self {
431        Self::for_auto()
432    }
433}
434
435impl DctToWaveletStageAccelerator for MetalDctToWaveletStageAccelerator {
436    fn supports_dwt97_batch(&self) -> bool {
437        true
438    }
439
440    fn supports_htj2k97_codeblock_batch(&self) -> bool {
441        true
442    }
443
444    fn dct_grid_to_reversible_dwt53(
445        &mut self,
446        job: DctGridToReversibleDwt53Job<'_>,
447    ) -> Result<Option<ReversibleDwt53FirstLevel>, TranscodeStageError> {
448        self.reversible_dwt53_attempts = self.reversible_dwt53_attempts.saturating_add(1);
449
450        // Auto declines with `Ok(None)`; see dispatch_reversible_dwt53_batch.
451        if self.mode == MetalDispatchMode::Auto
452            && job.width.saturating_mul(job.height) < self.min_auto_reversible_samples
453        {
454            return Ok(None);
455        }
456
457        #[cfg(not(target_os = "macos"))]
458        {
459            match self.mode {
460                MetalDispatchMode::Explicit => Err(TranscodeStageError::DeviceUnavailable),
461                MetalDispatchMode::Auto => Ok(None),
462            }
463        }
464
465        #[cfg(target_os = "macos")]
466        {
467            match metal::dispatch_dct_grid_to_reversible_dwt53(self.metal_session(), job) {
468                Ok(output) => {
469                    self.reversible_dwt53_dispatches =
470                        self.reversible_dwt53_dispatches.saturating_add(1);
471                    Ok(Some(output))
472                }
473                Err(
474                    MetalTranscodeError::MetalUnavailable | MetalTranscodeError::UnsupportedJob(_),
475                ) if self.mode == MetalDispatchMode::Auto => Ok(None),
476                Err(error) => Err(error.into()),
477            }
478        }
479    }
480
481    fn dct_grid_to_reversible_dwt53_batch(
482        &mut self,
483        jobs: &[DctGridToReversibleDwt53Job<'_>],
484    ) -> Result<Option<Vec<ReversibleDwt53FirstLevel>>, TranscodeStageError> {
485        self.dispatch_reversible_dwt53_batch(jobs)
486    }
487
488    fn dct_grid_to_dwt53(
489        &mut self,
490        job: DctGridToDwt53Job<'_>,
491    ) -> Result<Option<Dwt53TwoDimensional<f64>>, TranscodeStageError> {
492        self.dwt53_attempts = self.dwt53_attempts.saturating_add(1);
493
494        if self.mode == MetalDispatchMode::Auto
495            && job.width.saturating_mul(job.height) < self.min_auto_samples
496        {
497            return Ok(None);
498        }
499
500        #[cfg(not(target_os = "macos"))]
501        {
502            let _ = job;
503            match self.mode {
504                MetalDispatchMode::Explicit => Err(TranscodeStageError::DeviceUnavailable),
505                MetalDispatchMode::Auto => Ok(None),
506            }
507        }
508
509        #[cfg(target_os = "macos")]
510        {
511            match metal::dispatch_dct_grid_to_dwt53(self.metal_session(), job) {
512                Ok(output) => {
513                    self.dwt53_dispatches = self.dwt53_dispatches.saturating_add(1);
514                    Ok(Some(output))
515                }
516                Err(
517                    MetalTranscodeError::MetalUnavailable | MetalTranscodeError::UnsupportedJob(_),
518                ) if self.mode == MetalDispatchMode::Auto => Ok(None),
519                Err(error) => Err(error.into()),
520            }
521        }
522    }
523
524    fn dct_grid_to_dwt97(
525        &mut self,
526        job: DctGridToDwt97Job<'_>,
527    ) -> Result<Option<Dwt97TwoDimensional<f64>>, TranscodeStageError> {
528        self.dwt97_attempts = self.dwt97_attempts.saturating_add(1);
529
530        if self.mode == MetalDispatchMode::Auto
531            && job.width.saturating_mul(job.height) < self.min_auto_dwt97_samples
532        {
533            return Ok(None);
534        }
535
536        #[cfg(not(target_os = "macos"))]
537        {
538            let _ = job;
539            match self.mode {
540                MetalDispatchMode::Explicit => Err(TranscodeStageError::DeviceUnavailable),
541                MetalDispatchMode::Auto => Ok(None),
542            }
543        }
544
545        #[cfg(target_os = "macos")]
546        {
547            match metal::dispatch_dct_grid_to_dwt97(self.metal_session(), job) {
548                Ok(output) => {
549                    self.dwt97_dispatches = self.dwt97_dispatches.saturating_add(1);
550                    Ok(Some(output))
551                }
552                Err(
553                    MetalTranscodeError::MetalUnavailable | MetalTranscodeError::UnsupportedJob(_),
554                ) if self.mode == MetalDispatchMode::Auto => Ok(None),
555                Err(error) => Err(error.into()),
556            }
557        }
558    }
559
560    fn dct_grid_to_dwt97_batch(
561        &mut self,
562        jobs: &[DctGridToDwt97Job<'_>],
563    ) -> Result<Option<Vec<Dwt97TwoDimensional<f64>>>, TranscodeStageError> {
564        self.dwt97_batch_attempts = self.dwt97_batch_attempts.saturating_add(1);
565        self.last_dwt97_batch_stage_timings = None;
566
567        if jobs.is_empty() {
568            return Ok(Some(Vec::new()));
569        }
570
571        let total_samples = jobs.iter().fold(0usize, |total, job| {
572            total.saturating_add(job.width.saturating_mul(job.height))
573        });
574        if self.mode == MetalDispatchMode::Auto
575            && (jobs.len() < self.min_auto_dwt97_batch_jobs
576                || total_samples < self.min_auto_dwt97_batch_samples)
577        {
578            return Ok(None);
579        }
580        if self.mode == MetalDispatchMode::Auto
581            && jobs.iter().any(|job| {
582                job.width > MAX_AUTO_DWT97_STAGED_BATCH_AXIS
583                    || job.height > MAX_AUTO_DWT97_STAGED_BATCH_AXIS
584            })
585        {
586            return Ok(None);
587        }
588
589        #[cfg(not(target_os = "macos"))]
590        {
591            let _ = jobs;
592            match self.mode {
593                MetalDispatchMode::Explicit => Err(TranscodeStageError::DeviceUnavailable),
594                MetalDispatchMode::Auto => Ok(None),
595            }
596        }
597
598        #[cfg(target_os = "macos")]
599        {
600            match metal::dispatch_dct_grid_to_dwt97_batch(self.metal_session(), jobs) {
601                Ok((output, timings)) => {
602                    self.dwt97_batch_dispatches = self.dwt97_batch_dispatches.saturating_add(1);
603                    self.last_dwt97_batch_stage_timings = Some(timings);
604                    Ok(Some(output))
605                }
606                Err(
607                    MetalTranscodeError::MetalUnavailable | MetalTranscodeError::UnsupportedJob(_),
608                ) if self.mode == MetalDispatchMode::Auto => Ok(None),
609                Err(error) => Err(error.into()),
610            }
611        }
612    }
613
614    fn dct_grid_to_htj2k97_codeblock_batch(
615        &mut self,
616        jobs: &[DctGridToHtj2k97CodeBlockJob<'_>],
617        options: Htj2k97CodeBlockOptions,
618    ) -> Result<Option<Vec<PrequantizedHtj2k97Component>>, TranscodeStageError> {
619        self.dwt97_batch_attempts = self.dwt97_batch_attempts.saturating_add(1);
620        self.htj2k97_codeblock_batch_attempts =
621            self.htj2k97_codeblock_batch_attempts.saturating_add(1);
622        self.last_dwt97_batch_stage_timings = None;
623
624        if jobs.is_empty() {
625            return Ok(Some(Vec::new()));
626        }
627
628        let total_samples = jobs.iter().fold(0usize, |total, job| {
629            total.saturating_add(job.width.saturating_mul(job.height))
630        });
631        if self.mode == MetalDispatchMode::Auto
632            && (jobs.len() < self.min_auto_dwt97_batch_jobs
633                || total_samples < self.min_auto_dwt97_batch_samples)
634        {
635            return Ok(None);
636        }
637        if self.mode == MetalDispatchMode::Auto
638            && jobs.iter().any(|job| {
639                job.width > MAX_AUTO_DWT97_STAGED_BATCH_AXIS
640                    || job.height > MAX_AUTO_DWT97_STAGED_BATCH_AXIS
641            })
642        {
643            return Ok(None);
644        }
645
646        #[cfg(not(target_os = "macos"))]
647        {
648            let _ = (jobs, options);
649            match self.mode {
650                MetalDispatchMode::Explicit => Err(TranscodeStageError::DeviceUnavailable),
651                MetalDispatchMode::Auto => Ok(None),
652            }
653        }
654
655        #[cfg(target_os = "macos")]
656        {
657            match metal::dispatch_dct_grid_to_htj2k97_codeblock_batch(
658                self.metal_session(),
659                jobs,
660                options,
661            ) {
662                Ok((output, timings)) => {
663                    self.dwt97_batch_dispatches = self.dwt97_batch_dispatches.saturating_add(1);
664                    self.htj2k97_codeblock_batch_dispatches =
665                        self.htj2k97_codeblock_batch_dispatches.saturating_add(1);
666                    self.last_dwt97_batch_stage_timings = Some(timings);
667                    Ok(Some(output))
668                }
669                Err(
670                    MetalTranscodeError::MetalUnavailable | MetalTranscodeError::UnsupportedJob(_),
671                ) if self.mode == MetalDispatchMode::Auto => Ok(None),
672                Err(error) => Err(error.into()),
673            }
674        }
675    }
676
677    fn last_dwt97_batch_stage_timings(&self) -> Option<Dwt97BatchStageTimings> {
678        self.last_dwt97_batch_stage_timings
679    }
680}