Skip to main content

alpha2_render/
yuv.rs

1use crate::hdr::HdrToneMap;
2use crate::{
3    Color, ColorDiagnostic, ColorPolicy, RenderError, RenderSize, RenderedTexture, Renderer,
4    pipeline::{CachedSampledPipeline, CachedYuvPipeline},
5};
6use alpha2_media::{
7    ChromaLocation, ColorMatrix, ColorMetadata, ColorPrimaries, ColorRange, Orientation, Rect,
8    TransferFunction, YuvFormat,
9};
10use alpha2_shaders::{RGBA_PRESENT_WGSL, YUV_TO_LINEAR_WGSL, YUV16_TO_LINEAR_WGSL};
11use std::fmt::Debug;
12use std::sync::atomic::{AtomicBool, Ordering};
13use std::sync::{Arc, Mutex};
14
15use crate::pool::UploadTexture;
16use crate::uniform::UniformArenaLease;
17
18/// A rendered source texture paired with presentation work that can be folded
19/// into the command encoder of its first consumer.
20#[derive(Debug)]
21pub struct PreparedSourceTexture {
22    texture: RenderedTexture,
23    pending: Option<PendingSourceWork>,
24}
25
26impl PreparedSourceTexture {
27    /// Separates the sampleable output from its one-shot presentation work.
28    #[must_use]
29    pub fn into_parts(self) -> (RenderedTexture, Option<PendingSourceWork>) {
30        (self.texture, self.pending)
31    }
32
33    /// Returns the maximum GPU texture bytes retained until presentation.
34    #[must_use]
35    pub fn retained_bytes(&self) -> u64 {
36        texture_bytes(self.texture.size, self.texture.format).saturating_add(
37            self.pending
38                .as_ref()
39                .map_or(0, PendingSourceWork::retained_bytes),
40        )
41    }
42}
43
44/// One source-presentation pass waiting to be encoded by its first consumer.
45#[derive(Debug)]
46pub struct PendingSourceWork {
47    state: Mutex<Option<SourceWork>>,
48    retained_bytes: u64,
49    submitted: AtomicBool,
50}
51
52#[derive(Debug)]
53pub(crate) struct SourceWork {
54    pub(crate) label: &'static str,
55    /// Whether the pass converts color into the working space, which a
56    /// plane-for-plane repack does not.
57    pub(crate) conversion: bool,
58    pub(crate) pipeline: wgpu::RenderPipeline,
59    pub(crate) bind_group: wgpu::BindGroup,
60    pub(crate) output_view: wgpu::TextureView,
61    pub(crate) uniforms: UniformArenaLease,
62    pub(crate) uploads: Vec<UploadTexture>,
63    pub(crate) external_leases: Vec<Arc<dyn ExternalTextureLease>>,
64}
65
66/// Keeps an imported native texture and its decoder surface alive until the
67/// queue has completed the submission that samples it.
68pub trait ExternalTextureLease: Debug + Send + Sync + 'static {
69    /// Approximate native allocation bytes retained by this lease.
70    fn retained_bytes(&self) -> u64;
71
72    /// Stages native waits or signals on the renderer's next queue submission.
73    fn prepare_submission(&self) -> bool {
74        true
75    }
76
77    /// Flushes any native work accumulated by `prepare_submission`.
78    ///
79    /// # Errors
80    /// The error the frame fails with, normally
81    /// [`RenderError::ExternalTextureSync`] carrying what the native
82    /// synchronization's status said about the device.
83    fn flush_submission(&self) -> Result<(), RenderError> {
84        Ok(())
85    }
86
87    /// Called immediately after the command buffer that uses the resource is submitted.
88    fn submitted(&self) {}
89
90    /// Native signals this lease has already staged on the renderer's queue.
91    ///
92    /// Staging is idempotent per frame and cleared when the submission it
93    /// belongs to completes, so a lease that still carries one when the next
94    /// frame begins staging has leaked a signal from the previous frame onto
95    /// this one's submission. Only the debug assertion in
96    /// `stage_source_submissions` reads this.
97    fn staged_signals(&self) -> usize {
98        0
99    }
100}
101
102/// Native leases whose submission has completed, waiting to be dropped.
103///
104/// Parking them keeps `FFmpeg` frame release out of `wgpu`'s queue submission,
105/// where the shared-queue lock is held. See [`Renderer::submit`].
106#[derive(Debug, Default)]
107pub(crate) struct RetiredLeases {
108    state: Mutex<LeaseState>,
109    /// Notified by each completion that parks leases, on whichever thread
110    /// runs it.
111    pub(crate) parks: crate::settle::CallbackSignal,
112    /// Test-only: each completion parks its leases from a thread of its own
113    /// this long after its callback ran, as one another thread's poll
114    /// collected does.
115    #[cfg(test)]
116    pub(crate) park_elsewhere_after: Mutex<Option<std::time::Duration>>,
117}
118
119#[derive(Debug, Default)]
120struct LeaseState {
121    parked: Vec<Arc<dyn ExternalTextureLease>>,
122    /// Submissions whose completion has yet to park their leases.
123    awaited: usize,
124}
125
126impl RetiredLeases {
127    fn lock(&self) -> std::sync::MutexGuard<'_, LeaseState> {
128        self.state
129            .lock()
130            .unwrap_or_else(std::sync::PoisonError::into_inner)
131    }
132
133    /// Counts a submission whose completion will park leases.
134    fn await_completion(&self) {
135        self.lock().awaited += 1;
136    }
137
138    fn park(&self, leases: Vec<Arc<dyn ExternalTextureLease>>) {
139        {
140            let mut state = self.lock();
141            state.parked.extend(leases);
142            state.awaited = state.awaited.saturating_sub(1);
143        }
144        self.parks.notify();
145    }
146
147    /// How many submitted completions have not parked their leases yet.
148    #[cfg(not(target_arch = "wasm32"))]
149    pub(crate) fn awaited(&self) -> usize {
150        self.lock().awaited
151    }
152
153    /// Drops every parked lease and reports how many there were.
154    ///
155    /// Called only outside a queue submission.
156    pub(crate) fn drain(&self) -> usize {
157        let leases = std::mem::take(&mut self.lock().parked);
158        let released = leases.len();
159        drop(leases);
160        released
161    }
162}
163
164pub(crate) struct SourceWorkTicket<'a> {
165    owner: &'a PendingSourceWork,
166    work: Option<SourceWork>,
167}
168
169impl PendingSourceWork {
170    pub(crate) fn new(work: SourceWork) -> Self {
171        let retained_bytes = work
172            .uploads
173            .iter()
174            .fold(0_u64, |total, input| {
175                total.saturating_add(input.retained_bytes())
176            })
177            .saturating_add(work.external_leases.iter().fold(0_u64, |total, lease| {
178                total.saturating_add(lease.retained_bytes())
179            }));
180        Self {
181            state: Mutex::new(Some(work)),
182            retained_bytes,
183            submitted: AtomicBool::new(false),
184        }
185    }
186
187    /// Returns bytes retained only until the pending pass is submitted.
188    #[must_use]
189    pub fn retained_bytes(&self) -> u64 {
190        if self.submitted.load(Ordering::Acquire) {
191            0
192        } else {
193            self.retained_bytes
194        }
195    }
196
197    /// Takes the work, unless a frame already has; see
198    /// [`crate::graph_submit::begin_source_work`] for why only under the
199    /// frame lock.
200    pub(crate) fn begin(
201        &self,
202        _frame: &crate::renderer::FrameGuard,
203    ) -> Option<SourceWorkTicket<'_>> {
204        let work = self
205            .state
206            .lock()
207            .unwrap_or_else(std::sync::PoisonError::into_inner)
208            .take()?;
209        Some(SourceWorkTicket {
210            owner: self,
211            work: Some(work),
212        })
213    }
214}
215
216impl SourceWorkTicket<'_> {
217    pub(crate) fn encode(&self, encoder: &mut wgpu::CommandEncoder) {
218        let work = self.work.as_ref().expect("live source work ticket");
219        let mut pass = encoder.begin_render_pass(&wgpu::RenderPassDescriptor {
220            label: Some(work.label),
221            color_attachments: &[Some(wgpu::RenderPassColorAttachment {
222                view: &work.output_view,
223                depth_slice: None,
224                resolve_target: None,
225                ops: wgpu::Operations {
226                    load: wgpu::LoadOp::Clear(wgpu::Color::TRANSPARENT),
227                    store: wgpu::StoreOp::Store,
228                },
229            })],
230            depth_stencil_attachment: None,
231            timestamp_writes: None,
232            occlusion_query_set: None,
233            multiview_mask: None,
234        });
235        pass.set_pipeline(&work.pipeline);
236        pass.set_bind_group(0, &work.bind_group, &[]);
237        pass.draw(0..3, 0..1);
238    }
239
240    pub(crate) fn flush_uniforms(&self, queue: &wgpu::Queue) {
241        self.work
242            .as_ref()
243            .expect("live source work ticket")
244            .uniforms
245            .flush(queue);
246    }
247
248    /// Whether no lease in this ticket has a signal staged yet.
249    pub(crate) fn has_no_staged_signals(&self) -> bool {
250        self.work.as_ref().is_none_or(|work| {
251            work.external_leases
252                .iter()
253                .all(|lease| lease.staged_signals() == 0)
254        })
255    }
256
257    pub(crate) fn prepare_submission(&self) -> bool {
258        let work = self.work.as_ref().expect("live source work ticket");
259        work.external_leases
260            .iter()
261            .all(|lease| lease.prepare_submission())
262    }
263
264    pub(crate) fn flush_submission(&self) -> Result<(), RenderError> {
265        let work = self.work.as_ref().expect("live source work ticket");
266        work.external_leases
267            .iter()
268            .try_for_each(|lease| lease.flush_submission())
269    }
270
271    pub(crate) fn complete(mut self, renderer: &Renderer, standalone_submission: bool) {
272        let work = self.work.take().expect("live source work ticket");
273        let work_conversion = work.conversion;
274        work.uniforms.recycle();
275        drop(work.uploads);
276        if !work.external_leases.is_empty() {
277            let leases = work.external_leases;
278            for lease in &leases {
279                lease.submitted();
280            }
281            // The callback runs inside `wgpu`'s submission, so it only parks the leases:
282            // dropping them here inverts `FFmpeg`'s frame lock order and deadlocks.
283            let retired = Arc::clone(&renderer.retired_leases);
284            retired.await_completion();
285            #[cfg(test)]
286            let elsewhere = *retired
287                .park_elsewhere_after
288                .lock()
289                .unwrap_or_else(std::sync::PoisonError::into_inner);
290            renderer.gpu.on_submitted_work_done(move || {
291                #[cfg(test)]
292                if let Some(delay) = elsewhere {
293                    std::thread::spawn(move || {
294                        std::thread::sleep(delay);
295                        retired.park(leases);
296                    });
297                    return;
298                }
299                retired.park(leases);
300            });
301        }
302        self.owner.submitted.store(true, Ordering::Release);
303        crate::diagnostics::record_source_work(
304            &renderer.diagnostics,
305            1,
306            standalone_submission,
307            work_conversion,
308        );
309    }
310}
311
312impl Drop for SourceWorkTicket<'_> {
313    fn drop(&mut self) {
314        let Some(work) = self.work.take() else {
315            return;
316        };
317        let mut state = self
318            .owner
319            .state
320            .lock()
321            .unwrap_or_else(std::sync::PoisonError::into_inner);
322        debug_assert!(state.is_none());
323        *state = Some(work);
324    }
325}
326
327/// How an RGBA source texture stores color relative to its alpha.
328#[derive(Copy, Clone, Debug, Eq, PartialEq)]
329pub enum SourceAlpha {
330    /// Color independent of alpha, as decoders and `copyExternalImageToTexture`
331    /// without premultiplication deliver it. Presentation premultiplies it.
332    Straight,
333    /// Color already multiplied by alpha, as a browser 2D canvas stores it.
334    Premultiplied,
335}
336
337/// How the RGBA presentation pass reads its source texture.
338#[derive(Copy, Clone, Debug, Eq, PartialEq)]
339pub(crate) enum PresentedEncoding {
340    /// sRGB-encoded color, straight or premultiplied.
341    Srgb(SourceAlpha),
342    /// Premultiplied linear-light color in BT.709 primaries, encoded with the
343    /// sRGB curve extended to values above `1.0` and below `0.0`.
344    Linear,
345}
346
347/// GPU presentation transform applied while converting a decoded source.
348#[derive(Copy, Clone, Debug, Eq, PartialEq)]
349pub struct SourcePresentation {
350    /// Destination canvas. Pixels outside the contained source are transparent.
351    pub output_size: RenderSize,
352    /// Visible crop inside the coded source.
353    pub visible_rect: Rect,
354    /// How the source is shown upright: mirrored, then rotated.
355    pub orientation: Orientation,
356    /// Horizontal pixel aspect-ratio numerator and denominator.
357    pub sample_aspect_ratio: (u32, u32),
358    /// Chroma sample position. An unspecified location is sampled as centered.
359    pub chroma_siting: ChromaLocation,
360}
361
362impl SourcePresentation {
363    /// Creates an unrotated, square-pixel full-frame presentation.
364    #[must_use]
365    pub const fn identity(size: RenderSize) -> Self {
366        Self {
367            output_size: size,
368            visible_rect: Rect {
369                x: 0,
370                y: 0,
371                width: size.width,
372                height: size.height,
373            },
374            orientation: Orientation::UPRIGHT,
375            sample_aspect_ratio: (1, 1),
376            chroma_siting: ChromaLocation::Unspecified,
377        }
378    }
379}
380
381/// Borrowed 8-bit planar 4:2:0 source.
382#[derive(Copy, Clone, Debug)]
383pub struct Yuv420Frame<'a> {
384    /// Luma bytes including row padding.
385    pub y: &'a [u8],
386    /// Blue-difference chroma bytes including row padding.
387    pub u: &'a [u8],
388    /// Red-difference chroma bytes including row padding.
389    pub v: &'a [u8],
390    /// Luma row stride.
391    pub y_stride: u32,
392    /// Chroma row stride shared by U and V.
393    pub chroma_stride: u32,
394    /// Coded dimensions.
395    pub size: RenderSize,
396    /// Straight source alpha.
397    pub alpha: f32,
398    /// Optional stream color metadata.
399    pub color: Option<ColorMetadata>,
400}
401
402impl<'a> From<Yuv420Frame<'a>> for YuvFrame<'a> {
403    fn from(frame: Yuv420Frame<'a>) -> Self {
404        Self {
405            format: YuvFormat::I420,
406            planes: [
407                SourcePlane {
408                    bytes: frame.y,
409                    stride: frame.y_stride,
410                },
411                SourcePlane {
412                    bytes: frame.u,
413                    stride: frame.chroma_stride,
414                },
415                SourcePlane {
416                    bytes: frame.v,
417                    stride: frame.chroma_stride,
418                },
419            ],
420            alpha_plane: None,
421            size: frame.size,
422            alpha: frame.alpha,
423            color: frame.color,
424        }
425    }
426}
427
428/// One borrowed source plane: rows `stride` bytes apart.
429#[derive(Copy, Clone, Debug)]
430pub struct SourcePlane<'a> {
431    /// Plane bytes including row padding.
432    pub bytes: &'a [u8],
433    /// Bytes between adjacent rows.
434    pub stride: u32,
435}
436
437/// Borrowed planar or semi-planar YCbCr source of any subsampling and depth
438/// [`YuvFormat::is_supported`] accepts.
439#[derive(Copy, Clone, Debug)]
440pub struct YuvFrame<'a> {
441    /// How the planes store their samples.
442    pub format: YuvFormat,
443    /// Luma, then U and V, or luma and the interleaved chroma plane, in
444    /// which case the third plane is ignored.
445    pub planes: [SourcePlane<'a>; 3],
446    /// Straight, full-range alpha at the luma's size and depth: present
447    /// exactly when [`YuvFormat::alpha`] is set.
448    pub alpha_plane: Option<SourcePlane<'a>>,
449    /// Coded dimensions.
450    pub size: RenderSize,
451    /// Straight source alpha.
452    pub alpha: f32,
453    /// Optional stream color metadata.
454    pub color: Option<ColorMetadata>,
455}
456
457/// How an uploaded plane stores samples on the GPU.
458#[derive(Copy, Clone, Debug, Eq, PartialEq)]
459enum PlaneStorage {
460    /// 8-bit samples as `R8Unorm`/`Rg8Unorm`.
461    Unorm8,
462    /// 16-bit containers as `R16Unorm`/`Rg16Unorm`.
463    Unorm16,
464    /// 16-bit containers as they are, in `R16Uint`/`Rg16Uint`, for a device
465    /// without 16-bit normalized textures (browser WebGPU never has them).
466    /// The conversion's `u16_planes` build filters and normalizes them, so
467    /// no sample is touched on the CPU.
468    Uint16,
469}
470
471impl PlaneStorage {
472    fn texture_format(self, interleaved: bool) -> wgpu::TextureFormat {
473        match (self, interleaved) {
474            (Self::Unorm8, false) => wgpu::TextureFormat::R8Unorm,
475            (Self::Unorm8, true) => wgpu::TextureFormat::Rg8Unorm,
476            (Self::Unorm16, false) => wgpu::TextureFormat::R16Unorm,
477            (Self::Unorm16, true) => wgpu::TextureFormat::Rg16Unorm,
478            (Self::Uint16, false) => wgpu::TextureFormat::R16Uint,
479            (Self::Uint16, true) => wgpu::TextureFormat::Rg16Uint,
480        }
481    }
482
483    /// Factor that turns a sampled value into `code / (2^depth - 1)`.
484    ///
485    /// A `u16` container samples as `container / 65535`, normalized by the
486    /// texture or by the conversion itself: a low-aligned 10-bit code
487    /// reaches only `1023 / 65535` of full scale, and `P010`'s high-aligned
488    /// one `65472 / 65535`.
489    #[allow(clippy::cast_precision_loss)]
490    fn code_scale(self, format: YuvFormat) -> f32 {
491        match self {
492            Self::Unorm8 => 1.0,
493            Self::Unorm16 | Self::Uint16 => {
494                let maximum = ((1_u32 << format.bit_depth) - 1) as f32;
495                let alignment = if format.msb_aligned {
496                    (1_u32 << (16 - u32::from(format.bit_depth))) as f32
497                } else {
498                    1.0
499                };
500                65535.0 / (maximum * alignment)
501            }
502        }
503    }
504}
505
506/// Borrowed views of a decoded YCbCr frame already resident on the GPU.
507#[derive(Copy, Clone, Debug)]
508#[cfg_attr(not(feature = "wgpu-interop"), doc(hidden))]
509pub struct GpuVideoPlanes<'a> {
510    /// Luma view.
511    pub y: &'a wgpu::TextureView,
512    /// U view, or the interleaved UV view.
513    pub chroma: &'a wgpu::TextureView,
514    /// V view; absent when chroma is interleaved.
515    pub v: Option<&'a wgpu::TextureView>,
516    /// How the views store their samples: `R8`/`RG8` for eight bits,
517    /// `R16`/`RG16` normalized containers above.
518    pub format: YuvFormat,
519    /// Coded dimensions.
520    pub size: RenderSize,
521    /// Straight source alpha.
522    pub alpha: f32,
523    /// Optional stream color metadata.
524    pub color: Option<ColorMetadata>,
525}
526
527impl Renderer {
528    /// Prepares an uploaded RGBA texture for presentation by its first frame
529    /// consumer, without issuing a queue submission.
530    ///
531    /// # Errors
532    /// Returns an image-data error for invalid presentation metadata.
533    pub fn prepare_rgba_presented(
534        &self,
535        source: UploadTexture,
536        source_size: RenderSize,
537        presentation: SourcePresentation,
538        alpha: SourceAlpha,
539    ) -> Result<PreparedSourceTexture, RenderError> {
540        let view = source.view().clone();
541        let prepared = self.prepare_rgba_presented_inner(
542            &view,
543            source_size,
544            presentation,
545            PresentedEncoding::Srgb(alpha),
546            vec![source],
547        )?;
548        Ok(prepared)
549    }
550
551    /// Prepares an uploaded texture of premultiplied linear-light RGBA in
552    /// BT.709 primaries ([`Self::upload_linear_rgba16f`]) for presentation
553    /// like [`Self::prepare_rgba_presented`]. It is filtered in linear light
554    /// and stored like every presented source, sRGB-encoded and
555    /// premultiplied, the curve extended to keep values above `1.0` and below
556    /// `0.0` as far as the intermediate format holds them. Color with zero
557    /// alpha is not kept.
558    ///
559    /// # Errors
560    /// Returns an image-data error for invalid presentation metadata.
561    pub fn prepare_linear_presented(
562        &self,
563        source: UploadTexture,
564        source_size: RenderSize,
565        presentation: SourcePresentation,
566    ) -> Result<PreparedSourceTexture, RenderError> {
567        let view = source.view().clone();
568        self.prepare_rgba_presented_inner(
569            &view,
570            source_size,
571            presentation,
572            PresentedEncoding::Linear,
573            vec![source],
574        )
575    }
576
577    /// Uploads a straight-alpha RGBA8 plane into a pooled texture and
578    /// prepares its presentation like [`Self::prepare_rgba_presented`].
579    ///
580    /// # Errors
581    /// Returns an image-data error for a plane shorter than `size` or invalid
582    /// presentation metadata.
583    pub fn prepare_rgba8_presented(
584        &self,
585        pixels: SourcePlane<'_>,
586        size: RenderSize,
587        presentation: SourcePresentation,
588    ) -> Result<PreparedSourceTexture, RenderError> {
589        let texture = self.write_rgba8(size, pixels)?;
590        self.prepare_rgba_presented(texture, size, presentation, SourceAlpha::Straight)
591    }
592
593    pub(crate) fn prepare_rgba_presented_inner(
594        &self,
595        source_view: &wgpu::TextureView,
596        source_size: RenderSize,
597        presentation: SourcePresentation,
598        encoding: PresentedEncoding,
599        inputs: Vec<UploadTexture>,
600    ) -> Result<PreparedSourceTexture, RenderError> {
601        self.validate_size(source_size)?;
602        self.validate_size(presentation.output_size)?;
603        validate_presentation(source_size, presentation)?;
604        let output_format = self.intermediate_format();
605        let output = self.textures.acquire(
606            self.gpu.device(),
607            presentation.output_size,
608            output_format,
609            "alpha2 RGBA presentation target",
610        );
611        let cached = self.rgba_presentation_pipeline();
612        let uniform_bytes = presentation_uniform_bytes(
613            source_size,
614            presentation,
615            encoding,
616            self.presentation_filter_samples(),
617        );
618        let mut uniforms = self.uniform_buffers.acquire(
619            self.gpu.device(),
620            u64::try_from(uniform_bytes.len()).unwrap_or(u64::MAX),
621        );
622        let uniform = uniforms.write(&uniform_bytes);
623        let bind_group = self
624            .gpu
625            .device()
626            .create_bind_group(&wgpu::BindGroupDescriptor {
627                label: Some("alpha2 RGBA presentation bind group"),
628                layout: &cached.bind_group_layout,
629                entries: &[
630                    wgpu::BindGroupEntry {
631                        binding: 0,
632                        resource: wgpu::BindingResource::TextureView(source_view),
633                    },
634                    wgpu::BindGroupEntry {
635                        binding: 1,
636                        resource: wgpu::BindingResource::Sampler(&cached.sampler),
637                    },
638                    wgpu::BindGroupEntry {
639                        binding: 2,
640                        resource: uniforms.binding(uniform),
641                    },
642                ],
643            });
644        let output_view = output.view().clone();
645        Ok(PreparedSourceTexture {
646            texture: RenderedTexture::pooled(presentation.output_size, output_format, output),
647            pending: Some(PendingSourceWork::new(SourceWork {
648                label: "alpha2 RGBA presentation pass",
649                conversion: true,
650                pipeline: cached.pipeline.clone(),
651                bind_group,
652                output_view,
653                uniforms,
654                uploads: inputs,
655                external_leases: Vec::new(),
656            })),
657        })
658    }
659
660    /// Submits one pending source presentation immediately.
661    ///
662    /// Returns `false` when another consumer already submitted the work, and
663    /// also when the source's lease cannot stage its native synchronization or
664    /// the submission fails: the work then stays pending, and a later
665    /// consumer's frame submits it.
666    pub fn submit_pending_source(&self, pending: &PendingSourceWork) -> bool {
667        let frame = self.frame_lock.hold();
668        let Some(ticket) = pending.begin(&frame) else {
669            return false;
670        };
671        let mut encoder = self.command_encoder("alpha2 source presentation encoder");
672        ticket.encode(&mut encoder);
673        ticket.flush_uniforms(self.gpu.queue());
674        if !ticket.prepare_submission() || ticket.flush_submission().is_err() {
675            return false;
676        }
677        if self.submit([encoder.finish()]).is_err() {
678            return false;
679        }
680        ticket.complete(self, true);
681        true
682    }
683
684    /// Uploads YUV planes and converts them into premultiplied sRGB storage
685    /// on the GPU, ready for effects, layer composition, and presentation.
686    /// Transfer decoding happens before sRGB encoding and premultiplication.
687    ///
688    /// # Errors
689    /// Returns an image-data error for malformed planes or alpha values, and
690    /// [`RenderError::UnsupportedHdr`] when [`ColorPolicy::RejectHdr`]
691    /// meets an HDR source (the other policies tone-map it or skip it, and
692    /// report which in the diagnostics).
693    #[allow(clippy::too_many_lines)]
694    pub fn render_yuv420p(
695        &self,
696        frame: Yuv420Frame<'_>,
697        policy: ColorPolicy,
698    ) -> Result<(RenderedTexture, Vec<ColorDiagnostic>), RenderError> {
699        self.render_yuv420p_presented(frame, SourcePresentation::identity(frame.size), policy)
700    }
701
702    fn rgba_presentation_pipeline(&self) -> &CachedSampledPipeline {
703        self.pipelines.rgba_present.get_or_init(|| {
704            let output_format = self.intermediate_format();
705            let bind_group_layout =
706                self.gpu
707                    .device()
708                    .create_bind_group_layout(&wgpu::BindGroupLayoutDescriptor {
709                        label: Some("alpha2 RGBA presentation layout"),
710                        entries: &[
711                            wgpu::BindGroupLayoutEntry {
712                                binding: 0,
713                                visibility: wgpu::ShaderStages::FRAGMENT,
714                                ty: wgpu::BindingType::Texture {
715                                    sample_type: wgpu::TextureSampleType::Float {
716                                        filterable: true,
717                                    },
718                                    view_dimension: wgpu::TextureViewDimension::D2,
719                                    multisampled: false,
720                                },
721                                count: None,
722                            },
723                            wgpu::BindGroupLayoutEntry {
724                                binding: 1,
725                                visibility: wgpu::ShaderStages::FRAGMENT,
726                                ty: wgpu::BindingType::Sampler(wgpu::SamplerBindingType::Filtering),
727                                count: None,
728                            },
729                            wgpu::BindGroupLayoutEntry {
730                                binding: 2,
731                                visibility: wgpu::ShaderStages::FRAGMENT,
732                                ty: wgpu::BindingType::Buffer {
733                                    ty: wgpu::BufferBindingType::Uniform,
734                                    has_dynamic_offset: false,
735                                    min_binding_size: None,
736                                },
737                                count: None,
738                            },
739                        ],
740                    });
741            let sampler = self.gpu.device().create_sampler(&wgpu::SamplerDescriptor {
742                label: Some("alpha2 RGBA presentation sampler"),
743                address_mode_u: wgpu::AddressMode::ClampToEdge,
744                address_mode_v: wgpu::AddressMode::ClampToEdge,
745                mag_filter: wgpu::FilterMode::Linear,
746                min_filter: wgpu::FilterMode::Linear,
747                ..Default::default()
748            });
749            let shader = self
750                .gpu
751                .device()
752                .create_shader_module(wgpu::ShaderModuleDescriptor {
753                    label: Some("alpha2 RGBA presentation shader"),
754                    source: wgpu::ShaderSource::Wgsl(RGBA_PRESENT_WGSL.into()),
755                });
756            let layout =
757                self.gpu
758                    .device()
759                    .create_pipeline_layout(&wgpu::PipelineLayoutDescriptor {
760                        label: Some("alpha2 RGBA presentation pipeline layout"),
761                        bind_group_layouts: &[Some(&bind_group_layout)],
762                        immediate_size: 0,
763                    });
764            let pipeline =
765                self.gpu
766                    .device()
767                    .create_render_pipeline(&wgpu::RenderPipelineDescriptor {
768                        label: Some("alpha2 RGBA presentation pipeline"),
769                        layout: Some(&layout),
770                        vertex: wgpu::VertexState {
771                            module: &shader,
772                            entry_point: Some("vs"),
773                            buffers: &[],
774                            compilation_options: wgpu::PipelineCompilationOptions::default(),
775                        },
776                        fragment: Some(wgpu::FragmentState {
777                            module: &shader,
778                            entry_point: Some("fs"),
779                            targets: &[Some(output_format.into())],
780                            compilation_options: wgpu::PipelineCompilationOptions::default(),
781                        }),
782                        primitive: wgpu::PrimitiveState::default(),
783                        depth_stencil: None,
784                        multisample: wgpu::MultisampleState::default(),
785                        multiview_mask: None,
786                        cache: None,
787                    });
788            CachedSampledPipeline {
789                bind_group_layout,
790                pipeline,
791                sampler,
792            }
793        })
794    }
795
796    /// Uploads and converts YUV420P while applying crop, sample aspect ratio,
797    /// rotation, scaling (bilinear, averaged over the footprint when it
798    /// shrinks), and transparent contain placement on GPU.
799    ///
800    /// # Errors
801    /// Returns an image-data error for malformed planes, presentation
802    /// metadata or alpha values, and
803    /// [`RenderError::UnsupportedHdr`] when [`ColorPolicy::RejectHdr`]
804    /// meets an HDR source (the other policies tone-map it or skip it, and
805    /// report which in the diagnostics).
806    #[allow(clippy::too_many_lines, clippy::cast_possible_truncation)]
807    pub fn render_yuv420p_presented(
808        &self,
809        frame: Yuv420Frame<'_>,
810        presentation: SourcePresentation,
811        policy: ColorPolicy,
812    ) -> Result<(RenderedTexture, Vec<ColorDiagnostic>), RenderError> {
813        let (prepared, diagnostics) =
814            self.prepare_yuv420p_presented(frame, presentation, policy)?;
815        let (texture, pending) = prepared.into_parts();
816        if let Some(pending) = pending {
817            self.submit_pending_source(&pending);
818        }
819        Ok((texture, diagnostics))
820    }
821
822    /// Uploads YUV420 planes and prepares their conversion for the command
823    /// encoder of the first frame that consumes the result.
824    ///
825    /// # Errors
826    /// Returns an image-data error for malformed input, and
827    /// [`RenderError::UnsupportedHdr`] when [`ColorPolicy::RejectHdr`]
828    /// meets an HDR source (the other policies tone-map it or skip it, and
829    /// report which in the diagnostics).
830    #[allow(clippy::too_many_lines, clippy::cast_possible_truncation)]
831    pub fn prepare_yuv420p_presented(
832        &self,
833        frame: Yuv420Frame<'_>,
834        presentation: SourcePresentation,
835        policy: ColorPolicy,
836    ) -> Result<(PreparedSourceTexture, Vec<ColorDiagnostic>), RenderError> {
837        self.prepare_yuv_presented(frame.into(), presentation, policy)
838    }
839
840    /// Uploads planar or semi-planar YCbCr of any supported subsampling and
841    /// depth, and prepares its conversion like
842    /// [`Self::prepare_yuv420p_presented`].
843    ///
844    /// Samples above eight bits upload as 16-bit normalized textures, or,
845    /// on a device without them, as the unsigned 16-bit containers they
846    /// are, which the conversion normalizes on the GPU.
847    ///
848    /// # Errors
849    /// Returns an image-data error for an unsupported format or malformed
850    /// planes, and
851    /// [`RenderError::UnsupportedHdr`] when [`ColorPolicy::RejectHdr`]
852    /// meets an HDR source (the other policies tone-map it or skip it, and
853    /// report which in the diagnostics).
854    pub fn prepare_yuv_presented(
855        &self,
856        frame: YuvFrame<'_>,
857        presentation: SourcePresentation,
858        policy: ColorPolicy,
859    ) -> Result<(PreparedSourceTexture, Vec<ColorDiagnostic>), RenderError> {
860        let mut diagnostics = Vec::new();
861        let color = frame.color.unwrap_or_else(|| {
862            diagnostics.push(ColorDiagnostic::MissingMetadataFallback);
863            ColorMetadata::UNDECLARED
864        });
865        let tone = match hdr_handling(color, policy, &mut diagnostics)? {
866            HdrHandling::Sdr => None,
867            HdrHandling::ToneMap(tone) => Some(tone),
868            HdrHandling::Skip => {
869                return self
870                    .render_solid(presentation.output_size, Color::rgba(0.0, 0.0, 0.0, 0.0))
871                    .map(|texture| {
872                        (
873                            PreparedSourceTexture {
874                                texture,
875                                pending: None,
876                            },
877                            diagnostics,
878                        )
879                    });
880            }
881        };
882        let format = frame.format;
883        if !frame.alpha.is_finite()
884            || !(0.0..=1.0).contains(&frame.alpha)
885            || !format.is_supported()
886            || format.alpha != frame.alpha_plane.is_some()
887        {
888            return Err(RenderError::InvalidImageData);
889        }
890        self.validate_size(presentation.output_size)?;
891        validate_presentation(frame.size, presentation)?;
892        let storage = if format.bytes_per_sample() == 1 {
893            PlaneStorage::Unorm8
894        } else if self
895            .features
896            .contains(wgpu::Features::TEXTURE_FORMAT_16BIT_NORM)
897        {
898            PlaneStorage::Unorm16
899        } else {
900            PlaneStorage::Uint16
901        };
902        let (chroma_width, chroma_height) = format.chroma_size(frame.size.width, frame.size.height);
903        let pairs = if format.interleaved_chroma { 2 } else { 1 };
904        let plane_specs = [
905            ("alpha2 Y plane", frame.size.width, frame.size.height, 1_u32),
906            ("alpha2 U plane", chroma_width, chroma_height, pairs),
907            ("alpha2 V plane", chroma_width, chroma_height, 1),
908        ];
909        let mut uploads = Vec::with_capacity(format.plane_count());
910        let alpha_spec = ("alpha2 A plane", frame.size.width, frame.size.height, 1_u32);
911        for (plane, (label, width, height, samples)) in frame
912            .planes
913            .iter()
914            .zip(plane_specs)
915            .take(format.color_plane_count())
916            .chain(frame.alpha_plane.iter().map(|plane| (plane, alpha_spec)))
917        {
918            let row_bytes = width
919                .checked_mul(samples)
920                .and_then(|row| row.checked_mul(format.bytes_per_sample()))
921                .ok_or(RenderError::InvalidImageData)?;
922            validate_plane(plane.bytes, plane.stride, row_bytes, height)?;
923            uploads.push(upload_plane(
924                self,
925                label,
926                *plane,
927                (width, height),
928                storage.texture_format(samples == 2),
929            )?);
930        }
931        let mut views = uploads
932            .iter()
933            .map(|upload| upload.view().clone())
934            .collect::<Vec<_>>();
935        let alpha_view = format.alpha.then(|| views.pop()).flatten();
936        let planes = GpuVideoPlanes {
937            y: &views[0],
938            chroma: &views[1],
939            v: views.get(2),
940            format,
941            size: frame.size,
942            alpha: frame.alpha,
943            color: Some(color),
944        };
945        let prepared = self.prepare_gpu_yuv_presented_inner(
946            planes,
947            alpha_view.as_ref(),
948            presentation,
949            tone,
950            storage,
951            uploads,
952            Vec::new(),
953        )?;
954        Ok((prepared, diagnostics))
955    }
956
957    /// Prepares conversion of native GPU video planes without copying them to CPU.
958    ///
959    /// The external lease is retained through queue completion. I420 accepts
960    /// three R8 views and NV12 accepts an R8 luma plus an RG8 chroma view.
961    ///
962    /// # Errors
963    /// Returns an image-data error for an invalid plane contract, and
964    /// [`RenderError::UnsupportedHdr`] when [`ColorPolicy::RejectHdr`]
965    /// meets an HDR source (the other policies tone-map it or skip it, and
966    /// report which in the diagnostics).
967    #[cfg_attr(not(feature = "wgpu-interop"), doc(hidden))]
968    pub fn prepare_gpu_yuv_presented(
969        &self,
970        planes: GpuVideoPlanes<'_>,
971        presentation: SourcePresentation,
972        policy: ColorPolicy,
973        lease: Arc<dyn ExternalTextureLease>,
974    ) -> Result<(PreparedSourceTexture, Vec<ColorDiagnostic>), RenderError> {
975        let mut diagnostics = Vec::new();
976        let color = planes.color.unwrap_or_else(|| {
977            diagnostics.push(ColorDiagnostic::MissingMetadataFallback);
978            ColorMetadata::UNDECLARED
979        });
980        let tone = match hdr_handling(color, policy, &mut diagnostics)? {
981            HdrHandling::Sdr => None,
982            HdrHandling::ToneMap(tone) => Some(tone),
983            HdrHandling::Skip => {
984                return self
985                    .render_solid(presentation.output_size, Color::rgba(0.0, 0.0, 0.0, 0.0))
986                    .map(|texture| {
987                        (
988                            PreparedSourceTexture {
989                                texture,
990                                pending: None,
991                            },
992                            diagnostics,
993                        )
994                    });
995            }
996        };
997        let planes = GpuVideoPlanes {
998            color: Some(color),
999            ..planes
1000        };
1001        let storage = if planes.format.bytes_per_sample() == 1 {
1002            PlaneStorage::Unorm8
1003        } else {
1004            PlaneStorage::Unorm16
1005        };
1006        let prepared = self.prepare_gpu_yuv_presented_inner(
1007            planes,
1008            None,
1009            presentation,
1010            tone,
1011            storage,
1012            Vec::new(),
1013            vec![lease],
1014        )?;
1015        Ok((prepared, diagnostics))
1016    }
1017
1018    /// Prepares the conversion of `planes`, stored as `storage`, with
1019    /// `alpha_plane` the straight alpha of a format that has one.
1020    #[allow(clippy::too_many_arguments)]
1021    fn prepare_gpu_yuv_presented_inner(
1022        &self,
1023        planes: GpuVideoPlanes<'_>,
1024        alpha_plane: Option<&wgpu::TextureView>,
1025        presentation: SourcePresentation,
1026        tone: Option<HdrToneMap>,
1027        storage: PlaneStorage,
1028        uploads: Vec<UploadTexture>,
1029        external_leases: Vec<Arc<dyn ExternalTextureLease>>,
1030    ) -> Result<PreparedSourceTexture, RenderError> {
1031        if !planes.alpha.is_finite() || !(0.0..=1.0).contains(&planes.alpha) {
1032            return Err(RenderError::InvalidImageData);
1033        }
1034        if !planes.format.is_supported()
1035            || planes.format.interleaved_chroma == planes.v.is_some()
1036            || planes.format.alpha != alpha_plane.is_some()
1037        {
1038            return Err(RenderError::InvalidImageData);
1039        }
1040        self.validate_size(planes.size)?;
1041        self.validate_size(presentation.output_size)?;
1042        validate_presentation(planes.size, presentation)?;
1043        let color = planes.color.unwrap_or_default();
1044        let output_format = self.intermediate_format();
1045        let output = self.textures.acquire(
1046            self.gpu.device(),
1047            presentation.output_size,
1048            output_format,
1049            "alpha2 YUV conversion target",
1050        );
1051        let output_view = output.view().clone();
1052        let cached = self.yuv_pipeline(storage == PlaneStorage::Uint16);
1053        let uniform_bytes = uniform_bytes(
1054            color,
1055            tone,
1056            planes.alpha,
1057            planes.size,
1058            presentation,
1059            planes.format,
1060            storage.code_scale(planes.format),
1061            self.presentation_filter_samples(),
1062        );
1063        let mut uniforms = self.uniform_buffers.acquire(
1064            self.gpu.device(),
1065            u64::try_from(uniform_bytes.len()).unwrap_or(u64::MAX),
1066        );
1067        let uniform = uniforms.write(&uniform_bytes);
1068        let bind_group = self
1069            .gpu
1070            .device()
1071            .create_bind_group(&wgpu::BindGroupDescriptor {
1072                label: Some("alpha2 YUV bind group"),
1073                layout: &cached.bind_group_layout,
1074                entries: &[
1075                    wgpu::BindGroupEntry {
1076                        binding: 0,
1077                        resource: wgpu::BindingResource::TextureView(planes.y),
1078                    },
1079                    wgpu::BindGroupEntry {
1080                        binding: 1,
1081                        resource: wgpu::BindingResource::TextureView(planes.chroma),
1082                    },
1083                    wgpu::BindGroupEntry {
1084                        binding: 2,
1085                        resource: wgpu::BindingResource::TextureView(
1086                            planes.v.unwrap_or(planes.chroma),
1087                        ),
1088                    },
1089                    wgpu::BindGroupEntry {
1090                        binding: 3,
1091                        resource: wgpu::BindingResource::Sampler(&cached.sampler),
1092                    },
1093                    wgpu::BindGroupEntry {
1094                        binding: 4,
1095                        resource: uniforms.binding(uniform),
1096                    },
1097                    wgpu::BindGroupEntry {
1098                        binding: 5,
1099                        // An opaque format reads no alpha; any plane fills the slot.
1100                        resource: wgpu::BindingResource::TextureView(
1101                            alpha_plane.unwrap_or(planes.y),
1102                        ),
1103                    },
1104                ],
1105            });
1106        Ok(PreparedSourceTexture {
1107            texture: RenderedTexture::pooled(presentation.output_size, output_format, output),
1108            pending: Some(PendingSourceWork::new(SourceWork {
1109                label: "alpha2 YUV conversion pass",
1110                conversion: true,
1111                pipeline: cached.pipeline.clone(),
1112                bind_group,
1113                output_view,
1114                uniforms,
1115                uploads,
1116                external_leases,
1117            })),
1118        })
1119    }
1120}
1121
1122pub(crate) fn validate_plane(
1123    bytes: &[u8],
1124    stride: u32,
1125    width: u32,
1126    height: u32,
1127) -> Result<(), RenderError> {
1128    let required = usize::try_from(stride).ok().and_then(|stride| {
1129        usize::try_from(height)
1130            .ok()
1131            .and_then(|height| stride.checked_mul(height))
1132    });
1133    if stride < width || required != Some(bytes.len()) {
1134        Err(RenderError::InvalidImageData)
1135    } else {
1136        Ok(())
1137    }
1138}
1139
1140fn texture_bytes(size: RenderSize, format: wgpu::TextureFormat) -> u64 {
1141    let block = format.block_copy_size(None).unwrap_or(4);
1142    u64::from(size.width)
1143        .saturating_mul(u64::from(size.height))
1144        .saturating_mul(u64::from(block))
1145}
1146
1147/// Uploads one plane of `width` by `height` sample positions as `texture_format`.
1148pub(crate) fn upload_plane(
1149    renderer: &Renderer,
1150    label: &'static str,
1151    plane: SourcePlane<'_>,
1152    (width, height): (u32, u32),
1153    texture_format: wgpu::TextureFormat,
1154) -> Result<crate::UploadTexture, RenderError> {
1155    let size = RenderSize::new(width, height)?;
1156    renderer.validate_size(size)?;
1157    let texture =
1158        renderer
1159            .upload_textures
1160            .acquire(renderer.gpu.device(), size, texture_format, label);
1161    renderer.gpu.queue().write_texture(
1162        wgpu::TexelCopyTextureInfo {
1163            texture: texture.texture(),
1164            mip_level: 0,
1165            origin: wgpu::Origin3d::ZERO,
1166            aspect: wgpu::TextureAspect::All,
1167        },
1168        plane.bytes,
1169        wgpu::TexelCopyBufferLayout {
1170            offset: 0,
1171            bytes_per_row: Some(plane.stride),
1172            rows_per_image: Some(height),
1173        },
1174        wgpu::Extent3d {
1175            width,
1176            height,
1177            depth_or_array_layers: 1,
1178        },
1179    );
1180    Ok(texture)
1181}
1182
1183impl Renderer {
1184    /// The most samples a presentation pass averages for one output pixel
1185    /// when it shrinks a source: the quality budget the composite's
1186    /// minification filter also takes, at most 64.
1187    #[allow(clippy::cast_precision_loss)]
1188    fn presentation_filter_samples(&self) -> f32 {
1189        self.quality_controls().max_filter_samples as f32
1190    }
1191
1192    /// The conversion's bindings: four planes (Y, U or UV, V, alpha) of
1193    /// `sample_type`, the plane sampler, and the uniform.
1194    fn yuv_bind_group_layout(&self, sample_type: wgpu::TextureSampleType) -> wgpu::BindGroupLayout {
1195        let texture = |binding| wgpu::BindGroupLayoutEntry {
1196            binding,
1197            visibility: wgpu::ShaderStages::FRAGMENT,
1198            ty: wgpu::BindingType::Texture {
1199                sample_type,
1200                view_dimension: wgpu::TextureViewDimension::D2,
1201                multisampled: false,
1202            },
1203            count: None,
1204        };
1205        self.gpu
1206            .device()
1207            .create_bind_group_layout(&wgpu::BindGroupLayoutDescriptor {
1208                label: Some("alpha2 YUV layout"),
1209                entries: &[
1210                    texture(0),
1211                    texture(1),
1212                    texture(2),
1213                    wgpu::BindGroupLayoutEntry {
1214                        binding: 3,
1215                        visibility: wgpu::ShaderStages::FRAGMENT,
1216                        ty: wgpu::BindingType::Sampler(wgpu::SamplerBindingType::Filtering),
1217                        count: None,
1218                    },
1219                    wgpu::BindGroupLayoutEntry {
1220                        binding: 4,
1221                        visibility: wgpu::ShaderStages::FRAGMENT,
1222                        ty: wgpu::BindingType::Buffer {
1223                            ty: wgpu::BufferBindingType::Uniform,
1224                            has_dynamic_offset: false,
1225                            min_binding_size: None,
1226                        },
1227                        count: None,
1228                    },
1229                    texture(5),
1230                ],
1231            })
1232    }
1233
1234    /// The conversion pipeline for normalized planes, or, with
1235    /// `integer_planes`, for 16-bit containers uploaded as unsigned integers.
1236    fn yuv_pipeline(&self, integer_planes: bool) -> &CachedYuvPipeline {
1237        let (cache, source) = if integer_planes {
1238            (&self.pipelines.yuv16, YUV16_TO_LINEAR_WGSL)
1239        } else {
1240            (&self.pipelines.yuv, YUV_TO_LINEAR_WGSL)
1241        };
1242        cache.get_or_init(|| {
1243            let bind_group_layout = self.yuv_bind_group_layout(if integer_planes {
1244                wgpu::TextureSampleType::Uint
1245            } else {
1246                wgpu::TextureSampleType::Float { filterable: true }
1247            });
1248            let shader = self
1249                .gpu
1250                .device()
1251                .create_shader_module(wgpu::ShaderModuleDescriptor {
1252                    label: Some("alpha2 YUV conversion shader"),
1253                    source: wgpu::ShaderSource::Wgsl(source.into()),
1254                });
1255            let pipeline_layout =
1256                self.gpu
1257                    .device()
1258                    .create_pipeline_layout(&wgpu::PipelineLayoutDescriptor {
1259                        label: Some("alpha2 YUV pipeline layout"),
1260                        bind_group_layouts: &[Some(&bind_group_layout)],
1261                        immediate_size: 0,
1262                    });
1263            let pipeline =
1264                self.gpu
1265                    .device()
1266                    .create_render_pipeline(&wgpu::RenderPipelineDescriptor {
1267                        label: Some("alpha2 YUV pipeline"),
1268                        layout: Some(&pipeline_layout),
1269                        vertex: wgpu::VertexState {
1270                            module: &shader,
1271                            entry_point: Some("vs"),
1272                            buffers: &[],
1273                            compilation_options: wgpu::PipelineCompilationOptions::default(),
1274                        },
1275                        fragment: Some(wgpu::FragmentState {
1276                            module: &shader,
1277                            entry_point: Some("fs_srgb"),
1278                            targets: &[Some(self.intermediate_format().into())],
1279                            compilation_options: wgpu::PipelineCompilationOptions::default(),
1280                        }),
1281                        primitive: wgpu::PrimitiveState::default(),
1282                        depth_stencil: None,
1283                        multisample: wgpu::MultisampleState::default(),
1284                        multiview_mask: None,
1285                        cache: None,
1286                    });
1287            let sampler = self.gpu.device().create_sampler(&wgpu::SamplerDescriptor {
1288                label: Some("alpha2 YUV sampler"),
1289                address_mode_u: wgpu::AddressMode::ClampToEdge,
1290                address_mode_v: wgpu::AddressMode::ClampToEdge,
1291                mag_filter: wgpu::FilterMode::Linear,
1292                min_filter: wgpu::FilterMode::Linear,
1293                ..Default::default()
1294            });
1295            CachedYuvPipeline {
1296                bind_group_layout,
1297                pipeline,
1298                sampler,
1299            }
1300        })
1301    }
1302}
1303
1304fn validate_presentation(
1305    source: RenderSize,
1306    presentation: SourcePresentation,
1307) -> Result<(), RenderError> {
1308    let visible = presentation.visible_rect;
1309    if visible.width == 0
1310        || visible.height == 0
1311        || presentation.sample_aspect_ratio.0 == 0
1312        || presentation.sample_aspect_ratio.1 == 0
1313        || visible
1314            .x
1315            .checked_add(visible.width)
1316            .is_none_or(|right| right > source.width)
1317        || visible
1318            .y
1319            .checked_add(visible.height)
1320            .is_none_or(|bottom| bottom > source.height)
1321    {
1322        Err(RenderError::InvalidImageData)
1323    } else {
1324        Ok(())
1325    }
1326}
1327
1328/// What the conversion does with a source's signal.
1329enum HdrHandling {
1330    /// An SDR signal, decoded as SDR.
1331    Sdr,
1332    /// An HDR signal tone-mapped into the working space.
1333    ToneMap(HdrToneMap),
1334    /// An HDR signal the policy skips: a transparent picture.
1335    Skip,
1336}
1337
1338/// How `policy` takes `color`, with its diagnostic recorded.
1339fn hdr_handling(
1340    color: ColorMetadata,
1341    policy: ColorPolicy,
1342    diagnostics: &mut Vec<ColorDiagnostic>,
1343) -> Result<HdrHandling, RenderError> {
1344    if !crate::is_hdr(color) {
1345        return Ok(HdrHandling::Sdr);
1346    }
1347    match policy {
1348        ColorPolicy::RejectHdr => {
1349            diagnostics.push(ColorDiagnostic::UnsupportedHdr);
1350            Err(RenderError::UnsupportedHdr)
1351        }
1352        ColorPolicy::WarnHdr => {
1353            diagnostics.push(ColorDiagnostic::UnsupportedHdr);
1354            Ok(HdrHandling::Skip)
1355        }
1356        ColorPolicy::ToneMapHdr(tone) => {
1357            diagnostics.push(ColorDiagnostic::ToneMappedHdr);
1358            Ok(HdrHandling::ToneMap(tone))
1359        }
1360    }
1361}
1362
1363/// The presentation shaders' `orientation` word: clockwise quarter turns in
1364/// the low two bits, and bit 2 set when the stored picture is mirrored first.
1365fn orientation_uniform(orientation: Orientation) -> u32 {
1366    orientation.rotation.quarter_turns() | (u32::from(orientation.mirror) << 2)
1367}
1368
1369#[allow(clippy::cast_possible_truncation, clippy::cast_precision_loss)]
1370fn presentation_uniform_bytes(
1371    source: RenderSize,
1372    presentation: SourcePresentation,
1373    encoding: PresentedEncoding,
1374    max_filter_samples: f32,
1375) -> Vec<u8> {
1376    let visible = presentation.visible_rect;
1377    let sar = f64::from(presentation.sample_aspect_ratio.0)
1378        / f64::from(presentation.sample_aspect_ratio.1);
1379    let unrotated_width = f64::from(visible.width) * sar;
1380    let unrotated_height = f64::from(visible.height);
1381    let (display_width, display_height) = if presentation.orientation.rotation.swaps_axes() {
1382        (unrotated_height, unrotated_width)
1383    } else {
1384        (unrotated_width, unrotated_height)
1385    };
1386    let scale = (f64::from(presentation.output_size.width) / display_width)
1387        .min(f64::from(presentation.output_size.height) / display_height);
1388    let draw_width = display_width * scale;
1389    let draw_height = display_height * scale;
1390    let draw_x = (f64::from(presentation.output_size.width) - draw_width) * 0.5;
1391    let draw_y = (f64::from(presentation.output_size.height) - draw_height) * 0.5;
1392    let values = [
1393        presentation.output_size.width as f32,
1394        presentation.output_size.height as f32,
1395        source.width as f32,
1396        source.height as f32,
1397        visible.x as f32,
1398        visible.y as f32,
1399        visible.width as f32,
1400        visible.height as f32,
1401        draw_x as f32,
1402        draw_y as f32,
1403        draw_width as f32,
1404        draw_height as f32,
1405    ];
1406    let mut bytes = Vec::with_capacity(64);
1407    for value in values {
1408        bytes.extend_from_slice(&value.to_ne_bytes());
1409    }
1410    bytes.extend_from_slice(&orientation_uniform(presentation.orientation).to_ne_bytes());
1411    let mode = match encoding {
1412        PresentedEncoding::Srgb(SourceAlpha::Straight) => 0_u32,
1413        PresentedEncoding::Srgb(SourceAlpha::Premultiplied) => 1,
1414        PresentedEncoding::Linear => 2,
1415    };
1416    bytes.extend_from_slice(&mode.to_ne_bytes());
1417    bytes.extend_from_slice(&max_filter_samples.to_ne_bytes());
1418    bytes.extend_from_slice(&[0; 4]);
1419    bytes
1420}
1421
1422#[allow(
1423    clippy::cast_possible_truncation,
1424    clippy::cast_precision_loss,
1425    clippy::too_many_arguments
1426)]
1427fn uniform_bytes(
1428    color: ColorMetadata,
1429    tone: Option<HdrToneMap>,
1430    alpha: f32,
1431    source: RenderSize,
1432    presentation: SourcePresentation,
1433    format: YuvFormat,
1434    code_scale: f32,
1435    max_filter_samples: f32,
1436) -> Vec<u8> {
1437    let mut bytes = color_uniform_bytes(color, tone, alpha, format.bit_depth, code_scale);
1438    // The word after `alpha` says whether an alpha plane is read, and the one after it
1439    // scales its samples to full range.
1440    let (alpha_plane, alpha_scale) = if format.alpha {
1441        (1_u32, code_scale)
1442    } else {
1443        (0, 0.0)
1444    };
1445    bytes[88..92].copy_from_slice(&alpha_plane.to_ne_bytes());
1446    bytes[92..96].copy_from_slice(&alpha_scale.to_ne_bytes());
1447    let visible = presentation.visible_rect;
1448    let sar = f64::from(presentation.sample_aspect_ratio.0)
1449        / f64::from(presentation.sample_aspect_ratio.1);
1450    let unrotated_width = f64::from(visible.width) * sar;
1451    let unrotated_height = f64::from(visible.height);
1452    let (display_width, display_height) = if presentation.orientation.rotation.swaps_axes() {
1453        (unrotated_height, unrotated_width)
1454    } else {
1455        (unrotated_width, unrotated_height)
1456    };
1457    let scale = (f64::from(presentation.output_size.width) / display_width)
1458        .min(f64::from(presentation.output_size.height) / display_height);
1459    let draw_width = display_width * scale;
1460    let draw_height = display_height * scale;
1461    let draw_x = (f64::from(presentation.output_size.width) - draw_width) * 0.5;
1462    let draw_y = (f64::from(presentation.output_size.height) - draw_height) * 0.5;
1463    let (chroma_width, chroma_height) = format.chroma_size(source.width, source.height);
1464    let chroma_size = [chroma_width as f32, chroma_height as f32];
1465    let factor_x = (1_u32 << format.chroma_shift_x) as f32;
1466    let factor_y = (1_u32 << format.chroma_shift_y) as f32;
1467    // A co-sited chroma sample sits `factor / 2 - 0.5` luma samples before its group's centre.
1468    let (cosited_x, cosited_y) = match presentation.chroma_siting {
1469        ChromaLocation::Left => (true, false),
1470        ChromaLocation::TopLeft => (true, true),
1471        ChromaLocation::Unspecified | ChromaLocation::Center => (false, false),
1472    };
1473    let chroma_offset = [
1474        if cosited_x { factor_x * 0.5 - 0.5 } else { 0.0 },
1475        if cosited_y { factor_y * 0.5 - 0.5 } else { 0.0 },
1476    ];
1477    let presentation_values = [
1478        presentation.output_size.width as f32,
1479        presentation.output_size.height as f32,
1480        source.width as f32,
1481        source.height as f32,
1482        visible.x as f32,
1483        visible.y as f32,
1484        visible.width as f32,
1485        visible.height as f32,
1486        draw_x as f32,
1487        draw_y as f32,
1488        draw_width as f32,
1489        draw_height as f32,
1490        chroma_size[0],
1491        chroma_size[1],
1492        chroma_offset[0],
1493        chroma_offset[1],
1494    ];
1495    for value in presentation_values {
1496        bytes.extend_from_slice(&value.to_ne_bytes());
1497    }
1498    bytes.extend_from_slice(&orientation_uniform(presentation.orientation).to_ne_bytes());
1499    let plane_layout = u32::from(format.interleaved_chroma);
1500    bytes.extend_from_slice(&plane_layout.to_ne_bytes());
1501    for scale in [1.0 / factor_x, 1.0 / factor_y] {
1502        bytes.extend_from_slice(&scale.to_ne_bytes());
1503    }
1504    bytes.extend(hdr_uniform_bytes(color, tone));
1505    for bound in chroma_window(visible, format) {
1506        bytes.extend_from_slice(&bound.to_ne_bytes());
1507    }
1508    for value in [max_filter_samples, 0.0, 0.0, 0.0] {
1509        bytes.extend_from_slice(&value.to_ne_bytes());
1510    }
1511    bytes
1512}
1513
1514/// The chroma samples that belong to `visible`, as the first and last sample
1515/// centres on each axis (`[x0, y0, x1, y1]`, in chroma sample positions): the
1516/// samples whose luma group reaches into the visible rect. Chroma sampling is
1517/// clamped to it, so the padding a decoder surface carries beyond its picture
1518/// never mixes into the picture's last row or column. When the surface is the
1519/// picture this is the whole plane, `0.5 ..= size - 0.5`.
1520#[allow(clippy::cast_precision_loss)]
1521fn chroma_window(visible: Rect, format: YuvFormat) -> [f32; 4] {
1522    let first = |origin: u32, shift: u8| (origin >> shift) as f32 + 0.5;
1523    let last =
1524        |origin: u32, length: u32, shift: u8| (origin + length).div_ceil(1 << shift) as f32 - 0.5;
1525    [
1526        first(visible.x, format.chroma_shift_x),
1527        first(visible.y, format.chroma_shift_y),
1528        last(visible.x, visible.width, format.chroma_shift_x),
1529        last(visible.y, visible.height, format.chroma_shift_y),
1530    ]
1531}
1532
1533/// The HDR part of the conversion uniform: the primaries matrix into BT.709,
1534/// the tone map's peaks and their PQ codes, and HLG's system gamma.
1535fn hdr_uniform_bytes(color: ColorMetadata, tone: Option<HdrToneMap>) -> Vec<u8> {
1536    let gamut = crate::hdr::gamut_matrix(color.primaries, ColorPrimaries::Bt709);
1537    let tone = tone.unwrap_or_default();
1538    #[allow(clippy::cast_precision_loss)]
1539    let peak = tone.source_peak_nits as f32;
1540    let white = crate::hdr::SDR_WHITE_NITS;
1541    let gamma = 1.2 + 0.42 * (peak / 1_000.0).log10();
1542    let mut bytes = Vec::with_capacity(80);
1543    for value in gamut
1544        .into_iter()
1545        .flat_map(|row| [row[0], row[1], row[2], 0.0])
1546        .chain([
1547            peak,
1548            white,
1549            crate::hdr::pq_inverse_eotf(peak),
1550            crate::hdr::pq_inverse_eotf(white),
1551            gamma,
1552            0.0,
1553            0.0,
1554            0.0,
1555        ])
1556    {
1557        bytes.extend_from_slice(&value.to_ne_bytes());
1558    }
1559    bytes
1560}
1561
1562fn color_uniform_bytes(
1563    color: ColorMetadata,
1564    tone: Option<HdrToneMap>,
1565    alpha: f32,
1566    bit_depth: u8,
1567    code_scale: f32,
1568) -> Vec<u8> {
1569    // 0 decodes SDR with the sRGB EOTF; 1 and 2 bring PQ and HLG in through the tone
1570    // map.
1571    let transfer: u32 = match (tone, color.transfer) {
1572        (Some(_), TransferFunction::Pq) => 1,
1573        (Some(_), TransferFunction::Hlg) => 2,
1574        _ => 0,
1575    };
1576    if color.matrix == ColorMatrix::Rgb {
1577        return identity_rgb_uniform_bytes(alpha, transfer);
1578    }
1579    let (kr, kb) = color
1580        .matrix
1581        .luma_weights()
1582        .expect("RGB components are handled above");
1583    let kg = 1.0 - kr - kb;
1584    let rows = [
1585        [1.0, 0.0, 2.0 * (1.0 - kr), 0.0],
1586        [
1587            1.0,
1588            -2.0 * kb * (1.0 - kb) / kg,
1589            -2.0 * kr * (1.0 - kr) / kg,
1590            0.0,
1591        ],
1592        [1.0, 2.0 * (1.0 - kb), 0.0, 0.0],
1593    ];
1594    let (scale, offset) = range_scale_offset(color.range, bit_depth, code_scale);
1595    let mut bytes = Vec::with_capacity(96);
1596    for value in rows.into_iter().flatten().chain(scale).chain(offset) {
1597        bytes.extend_from_slice(&value.to_ne_bytes());
1598    }
1599    bytes.extend_from_slice(&transfer.to_ne_bytes());
1600    bytes.extend_from_slice(&alpha.to_ne_bytes());
1601    bytes.extend_from_slice(&[0; 8]);
1602    bytes
1603}
1604
1605/// Component scale and offset that map sampled codes to normalized YCbCr.
1606///
1607/// The range's levels at the bit depth come from
1608/// [`ColorRange::levels`](alpha2_media::ColorRange::levels), the one
1609/// definition export packs with too. `code` is the storage's
1610/// [`PlaneStorage::code_scale`], folded in before the range conversion.
1611fn range_scale_offset(range: ColorRange, depth: u8, code: f32) -> ([f32; 4], [f32; 4]) {
1612    let levels = range.levels(depth);
1613    let (luma_scale, chroma_scale) = match range {
1614        ColorRange::Full => (code, code),
1615        ColorRange::Limited => (
1616            code * levels.maximum / levels.luma_span,
1617            code * levels.maximum / levels.chroma_span,
1618        ),
1619    };
1620    let chroma_offset = 0.0 - levels.chroma_center / levels.chroma_span;
1621    (
1622        [luma_scale, chroma_scale, chroma_scale, 0.0],
1623        [
1624            0.0 - levels.luma_black / levels.luma_span,
1625            chroma_offset,
1626            chroma_offset,
1627            0.0,
1628        ],
1629    )
1630}
1631
1632fn identity_rgb_uniform_bytes(alpha: f32, transfer: u32) -> Vec<u8> {
1633    let rows = [
1634        [1.0_f32, 0.0, 0.0, 0.0],
1635        [0.0_f32, 1.0, 0.0, 0.0],
1636        [0.0_f32, 0.0, 1.0, 0.0],
1637    ];
1638    let mut bytes = Vec::with_capacity(96);
1639    for value in rows
1640        .into_iter()
1641        .flatten()
1642        .chain([1.0, 1.0, 1.0, 0.0])
1643        .chain([0.0; 4])
1644    {
1645        bytes.extend_from_slice(&value.to_ne_bytes());
1646    }
1647    bytes.extend_from_slice(&transfer.to_ne_bytes());
1648    bytes.extend_from_slice(&alpha.to_ne_bytes());
1649    bytes.extend_from_slice(&[0; 8]);
1650    bytes
1651}
1652
1653#[cfg(test)]
1654mod tests {
1655    use super::*;
1656    use alpha2_media::{ColorPrimaries, DynamicRange};
1657
1658    #[test]
1659    fn parked_leases_are_dropped_only_when_drained() {
1660        // The callback runs under the decoder queue lock; dropping an FFmpeg frame there inverts
1661        // FFmpeg's frame lock order and deadlocks the export.
1662        #[derive(Debug)]
1663        struct CountingLease(Arc<std::sync::atomic::AtomicUsize>);
1664
1665        impl Drop for CountingLease {
1666            fn drop(&mut self) {
1667                self.0.fetch_add(1, Ordering::Release);
1668            }
1669        }
1670
1671        impl ExternalTextureLease for CountingLease {
1672            fn retained_bytes(&self) -> u64 {
1673                0
1674            }
1675        }
1676
1677        let drops = Arc::new(std::sync::atomic::AtomicUsize::new(0));
1678        let retired = RetiredLeases::default();
1679        let lease: Arc<dyn ExternalTextureLease> = Arc::new(CountingLease(Arc::clone(&drops)));
1680        retired.park(vec![lease]);
1681        assert_eq!(drops.load(Ordering::Acquire), 0, "parking must not release");
1682        retired.drain();
1683        assert_eq!(drops.load(Ordering::Acquire), 1, "drain must release");
1684        retired.drain();
1685        assert_eq!(drops.load(Ordering::Acquire), 1, "drain must be idempotent");
1686    }
1687
1688    #[test]
1689    fn ten_bit_limited_range_normalizes_the_p010_container() {
1690        let (scale, offset) = range_scale_offset(
1691            ColorRange::Limited,
1692            10,
1693            PlaneStorage::Unorm16.code_scale(YuvFormat::P010),
1694        );
1695        // A limited-range 10-bit black sample is code 64, stored as 64 << 6.
1696        let black = f32::from(64_u16 << 6) / 65535.0;
1697        assert!((black * scale[0] + offset[0]).abs() < 1e-4);
1698        // Nominal white is code 940.
1699        let white = f32::from(940_u16 << 6) / 65535.0;
1700        assert!((white * scale[0] + offset[0] - 1.0).abs() < 1e-4);
1701        // Neutral chroma is code 512.
1702        let neutral = f32::from(512_u16 << 6) / 65535.0;
1703        assert!((neutral * scale[1] + offset[1]).abs() < 1e-4);
1704    }
1705
1706    #[test]
1707    fn integer_containers_take_the_scale_normalized_ones_do() {
1708        // The integer build reads a container as `container / 65535`, as an R16Unorm
1709        // texture samples it.
1710        for format in [YuvFormat::planar(1, 1, 10), YuvFormat::P010] {
1711            assert_eq!(
1712                PlaneStorage::Uint16.code_scale(format).to_bits(),
1713                PlaneStorage::Unorm16.code_scale(format).to_bits()
1714            );
1715        }
1716        assert_eq!(
1717            PlaneStorage::Uint16.texture_format(false),
1718            wgpu::TextureFormat::R16Uint
1719        );
1720        assert_eq!(
1721            PlaneStorage::Uint16.texture_format(true),
1722            wgpu::TextureFormat::Rg16Uint
1723        );
1724    }
1725
1726    #[test]
1727    fn eight_bit_range_constants_are_unchanged() {
1728        let bits = |values: [f32; 4]| values.map(f32::to_bits);
1729        let (scale, offset) = range_scale_offset(ColorRange::Limited, 8, 1.0);
1730        assert_eq!(
1731            bits(scale),
1732            bits([255.0 / 219.0, 255.0 / 224.0, 255.0 / 224.0, 0.0])
1733        );
1734        assert_eq!(
1735            bits(offset),
1736            bits([-16.0 / 219.0, -128.0 / 224.0, -128.0 / 224.0, 0.0])
1737        );
1738        let (scale, offset) = range_scale_offset(ColorRange::Full, 8, 1.0);
1739        assert_eq!(bits(scale), bits([1.0, 1.0, 1.0, 0.0]));
1740        assert_eq!(
1741            bits(offset),
1742            bits([0.0, -128.0 / 255.0, -128.0 / 255.0, 0.0])
1743        );
1744    }
1745
1746    #[test]
1747    fn rgb_uniform_uses_identity_matrix_without_chroma_offsets() {
1748        let bytes = uniform_bytes(
1749            ColorMetadata {
1750                primaries: ColorPrimaries::Bt709,
1751                transfer: TransferFunction::Srgb,
1752                matrix: ColorMatrix::Rgb,
1753                range: ColorRange::Limited,
1754                dynamic_range: DynamicRange::Sdr,
1755            },
1756            None,
1757            1.0,
1758            RenderSize::new(2, 2).expect("size"),
1759            SourcePresentation::identity(RenderSize::new(2, 2).expect("size")),
1760            YuvFormat::I420,
1761            1.0,
1762            64.0,
1763        );
1764        let floats = bytes[..80]
1765            .as_chunks::<4>()
1766            .0
1767            .iter()
1768            .map(|bytes| f32::from_ne_bytes(*bytes))
1769            .collect::<Vec<_>>();
1770        assert_eq!(
1771            floats.into_iter().map(f32::to_bits).collect::<Vec<_>>(),
1772            vec![
1773                1.0, 0.0, 0.0, 0.0, // matrix row 0
1774                0.0, 1.0, 0.0, 0.0, // matrix row 1
1775                0.0, 0.0, 1.0, 0.0, // matrix row 2
1776                1.0, 1.0, 1.0, 0.0, // component scale
1777                0.0, 0.0, 0.0, 0.0, // component offset
1778            ]
1779            .into_iter()
1780            .map(f32::to_bits)
1781            .collect::<Vec<_>>()
1782        );
1783    }
1784
1785    #[test]
1786    fn the_chroma_window_is_the_visible_pictures_chroma() {
1787        let window = |x, y, width, height, format| {
1788            chroma_window(
1789                Rect {
1790                    x,
1791                    y,
1792                    width,
1793                    height,
1794                },
1795                format,
1796            )
1797            .map(f32::to_bits)
1798        };
1799        let bits = |bounds: [f32; 4]| bounds.map(f32::to_bits);
1800        // 1080p decoded into 1920 x 1088: chroma rows 540..544 are padding.
1801        assert_eq!(
1802            window(0, 0, 1920, 1080, YuvFormat::I420),
1803            bits([0.5, 0.5, 959.5, 539.5])
1804        );
1805        // 1366 wide in a 1376 surface: the last column's chroma is sample 682.
1806        assert_eq!(
1807            window(0, 0, 1366, 768, YuvFormat::I420),
1808            bits([0.5, 0.5, 682.5, 383.5])
1809        );
1810        // An odd crop keeps the chroma sample its first luma column shares.
1811        assert_eq!(
1812            window(1, 3, 5, 4, YuvFormat::I420),
1813            bits([0.5, 1.5, 2.5, 3.5])
1814        );
1815        assert_eq!(
1816            window(2, 1, 3, 2, YuvFormat::planar(0, 0, 8)),
1817            bits([2.5, 1.5, 4.5, 2.5])
1818        );
1819    }
1820}