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