Skip to main content

alpha2_render/cpu/
source.rs

1//! What a frame provider lends the CPU renderer as a graph source, and how
2//! a decoded frame becomes the picture a source shows: yuv-to-linear.wesl
3//! and rgba-present.wesl, run on the CPU over the presented picture's rows.
4//!
5//! The planes are not copied: a source holds the decoder's buffers and
6//! converts from them the first time a graph draws it.
7
8use std::fmt;
9use std::sync::atomic::Ordering;
10use std::sync::{Arc, OnceLock};
11
12use alpha2_media::{ColorMetadata, YuvFormat};
13
14use super::CpuRenderer;
15use super::curves::{exact_srgb_to_linear, extended_linear_to_srgb, linear_to_srgb};
16use super::half;
17use super::image::{CpuImage, PixelBox, Pixels};
18use crate::hdr::HdrToneMap;
19use crate::yuv::{
20    HdrHandling, PlaneStorage, PresentedEncoding, hdr_handling, presentation_uniform_bytes,
21    uniform_bytes, validate_presentation,
22};
23use crate::{
24    ColorDiagnostic, ColorPolicy, RenderError, RenderSize, SourceAlpha, SourcePlane,
25    SourcePresentation, YuvFrame,
26};
27
28/// Decoded YCbCr planes a source converts from, owned by whoever decoded
29/// them (a decoder's frame buffer), and kept unchanged while any source
30/// holds them.
31pub trait YuvPlanes: Send + Sync + fmt::Debug {
32    /// The frame the planes hold.
33    fn frame(&self) -> YuvFrame<'_>;
34}
35
36/// RGBA pixels a source converts from: 8-bit, or half-float bits for
37/// linear-light sources.
38pub trait RgbaPixels: Send + Sync + fmt::Debug {
39    /// The pixels' bytes, rows `stride` bytes apart.
40    fn plane(&self) -> SourcePlane<'_>;
41}
42
43impl RgbaPixels for Vec<u8> {
44    fn plane(&self) -> SourcePlane<'_> {
45        SourcePlane {
46            bytes: self,
47            stride: 0,
48        }
49    }
50}
51
52/// A picture the CPU renderer draws from as a graph source
53/// ([`crate::SourceView::cpu`]): premultiplied, sRGB-encoded RGBA, as every
54/// GPU source texture holds.
55///
56/// A source made from a decoded frame converts it the first time a graph
57/// draws it, and keeps the picture for every later draw.
58#[derive(Clone)]
59pub struct CpuSource {
60    inner: Arc<SourceInner>,
61}
62
63struct SourceInner {
64    content: Content,
65    presented: OnceLock<Arc<CpuImage>>,
66}
67
68enum Content {
69    Image(Arc<CpuImage>),
70    Yuv {
71        planes: Arc<dyn YuvPlanes>,
72        conversion: Box<YuvConversion>,
73    },
74    Rgba {
75        pixels: Arc<dyn RgbaPixels>,
76        stride: u32,
77        size: RenderSize,
78        geometry: Geometry,
79        encoding: PresentedEncoding,
80    },
81}
82
83impl fmt::Debug for CpuSource {
84    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
85        let kind = match &self.inner.content {
86            Content::Image(_) => "image",
87            Content::Yuv { .. } => "yuv",
88            Content::Rgba { .. } => "rgba",
89        };
90        formatter
91            .debug_struct("CpuSource")
92            .field("kind", &kind)
93            .field("presented", &self.inner.presented.get().is_some())
94            .finish()
95    }
96}
97
98impl CpuSource {
99    /// A source that shows `picture` as it is.
100    #[must_use]
101    pub fn image(picture: Arc<CpuImage>) -> Self {
102        Self::new(Content::Image(picture))
103    }
104
105    fn new(content: Content) -> Self {
106        Self {
107            inner: Arc::new(SourceInner {
108                content,
109                presented: OnceLock::new(),
110            }),
111        }
112    }
113
114    /// Bytes the source holds: its picture once presented.
115    #[must_use]
116    pub fn retained_bytes(&self) -> u64 {
117        match &self.inner.content {
118            Content::Image(image) => image.retained_bytes(),
119            _ => self
120                .inner
121                .presented
122                .get()
123                .map_or(0, |image| image.retained_bytes()),
124        }
125    }
126
127    /// The picture the source shows, converting it on first use.
128    pub(crate) fn presented(&self, renderer: &CpuRenderer) -> Arc<CpuImage> {
129        if let Content::Image(image) = &self.inner.content {
130            return Arc::clone(image);
131        }
132        if let Some(image) = self.inner.presented.get() {
133            return Arc::clone(image);
134        }
135        let image = Arc::new(match &self.inner.content {
136            Content::Image(_) => unreachable!("handled above"),
137            Content::Yuv { planes, conversion } => {
138                present_yuv(renderer, &planes.frame(), conversion)
139            }
140            Content::Rgba {
141                pixels,
142                stride,
143                size,
144                geometry,
145                encoding,
146            } => present_rgba(
147                renderer,
148                &RgbaSource {
149                    bytes: pixels.plane().bytes,
150                    stride: *stride as usize,
151                    size: *size,
152                    encoding: *encoding,
153                },
154                geometry,
155            ),
156        });
157        renderer
158            .stats
159            .presented_sources
160            .fetch_add(1, Ordering::Relaxed);
161        Arc::clone(self.inner.presented.get_or_init(|| image))
162    }
163}
164
165/// The presentation uniforms the shaders take, read from the very bytes the
166/// GPU path binds, so both paths convert with the same values.
167#[derive(Clone, Copy, Debug)]
168struct Geometry {
169    output: [f32; 2],
170    source: [f32; 2],
171    visible: [f32; 4],
172    draw: [f32; 4],
173    orientation: u32,
174    max_filter_samples: f32,
175}
176
177#[derive(Clone, Debug)]
178#[allow(clippy::struct_excessive_bools)]
179struct YuvConversion {
180    rows: [[f32; 3]; 3],
181    range_scale: [f32; 3],
182    range_offset: [f32; 3],
183    transfer: u32,
184    alpha: f32,
185    alpha_plane: bool,
186    alpha_scale: f32,
187    geometry: Geometry,
188    chroma_size: [f32; 2],
189    chroma_offset: [f32; 2],
190    interleaved: bool,
191    chroma_scale: [f32; 2],
192    gamut: [[f32; 3]; 3],
193    tone: [f32; 4],
194    hlg_gamma: f32,
195    chroma_window: [f32; 4],
196    format: YuvFormat,
197    /// SDR with BT.709 primaries and no alpha plane: decoding with the sRGB
198    /// EOTF and encoding again is the identity, so the conversion keeps the
199    /// encoded value.
200    encoded_identity: bool,
201    /// The HDR policy skipped the source: it shows nothing.
202    skipped: bool,
203}
204
205fn word(bytes: &[u8], offset: usize) -> [u8; 4] {
206    bytes[offset..offset + 4].try_into().expect("four bytes")
207}
208
209fn float(bytes: &[u8], offset: usize) -> f32 {
210    f32::from_ne_bytes(word(bytes, offset))
211}
212
213fn unsigned(bytes: &[u8], offset: usize) -> u32 {
214    u32::from_ne_bytes(word(bytes, offset))
215}
216
217fn floats<const N: usize>(bytes: &[u8], offset: usize) -> [f32; N] {
218    std::array::from_fn(|index| float(bytes, offset + index * 4))
219}
220
221impl YuvConversion {
222    /// The conversion from `YuvUniforms` (yuv-to-linear.wesl) as
223    /// `uniform_bytes` writes it.
224    fn from_uniform(bytes: &[u8], format: YuvFormat) -> Self {
225        let rows = [floats::<3>(bytes, 0), floats(bytes, 16), floats(bytes, 32)];
226        let gamut = [
227            floats::<3>(bytes, 176),
228            floats(bytes, 192),
229            floats(bytes, 208),
230        ];
231        let transfer = unsigned(bytes, 80);
232        let alpha_plane = unsigned(bytes, 88) != 0;
233        let identity = gamut.iter().enumerate().all(|(row, values)| {
234            values.iter().enumerate().all(|(column, value)| {
235                let expected = if row == column { 1.0 } else { 0.0 };
236                (value - expected).abs() <= 1.0e-6
237            })
238        });
239        let size = floats::<4>(bytes, 96);
240        Self {
241            rows,
242            range_scale: floats(bytes, 48),
243            range_offset: floats(bytes, 64),
244            transfer,
245            alpha: float(bytes, 84),
246            alpha_plane,
247            alpha_scale: float(bytes, 92),
248            geometry: Geometry {
249                output: [size[0], size[1]],
250                source: [size[2], size[3]],
251                visible: floats(bytes, 112),
252                draw: floats(bytes, 128),
253                orientation: unsigned(bytes, 160),
254                max_filter_samples: float(bytes, 272),
255            },
256            chroma_size: floats(bytes, 144),
257            chroma_offset: floats(bytes, 152),
258            interleaved: unsigned(bytes, 164) == 1,
259            chroma_scale: floats(bytes, 168),
260            gamut,
261            tone: floats(bytes, 224),
262            hlg_gamma: float(bytes, 240),
263            chroma_window: floats(bytes, 256),
264            format,
265            encoded_identity: transfer == 0 && identity && !alpha_plane,
266            skipped: false,
267        }
268    }
269}
270
271impl Geometry {
272    /// From `PresentationParams` (rgba-present.wesl).
273    fn from_uniform(bytes: &[u8]) -> Self {
274        let size = floats::<4>(bytes, 0);
275        Self {
276            output: [size[0], size[1]],
277            source: [size[2], size[3]],
278            visible: floats(bytes, 16),
279            draw: floats(bytes, 32),
280            orientation: unsigned(bytes, 48),
281            max_filter_samples: float(bytes, 56),
282        }
283    }
284
285    /// The output pixels inside the drawn rectangle: everything else is
286    /// transparent.
287    #[allow(clippy::cast_possible_truncation, clippy::cast_sign_loss)]
288    fn bounds(&self, size: RenderSize) -> PixelBox {
289        // Pixel centres p + 0.5 inside [draw.x, draw.x + draw.w).
290        let first = |origin: f32| (origin - 0.5).ceil().max(0.0) as u32;
291        let end = |origin: f32, extent: f32| ((origin + extent - 0.5).ceil()).max(0.0) as u32;
292        let x = first(self.draw[0]);
293        let y = first(self.draw[1]);
294        let right = end(self.draw[0], self.draw[2]).min(size.width);
295        let bottom = end(self.draw[1], self.draw[3]).min(size.height);
296        if right <= x || bottom <= y {
297            return PixelBox::EMPTY;
298        }
299        PixelBox {
300            x,
301            y,
302            width: right - x,
303            height: bottom - y,
304        }
305    }
306
307    fn inside(&self, destination: [f32; 2]) -> bool {
308        destination[0] >= self.draw[0]
309            && destination[1] >= self.draw[1]
310            && destination[0] < self.draw[0] + self.draw[2]
311            && destination[1] < self.draw[1] + self.draw[3]
312    }
313
314    /// `source_pixel_at`: the stored pixel position output position
315    /// `destination` shows, kept inside the visible rect.
316    fn source_pixel_at(&self, destination: [f32; 2]) -> [f32; 2] {
317        let local = [
318            (destination[0] - self.draw[0]) / self.draw[2],
319            (destination[1] - self.draw[1]) / self.draw[3],
320        ];
321        let mut source = match self.orientation & 3 {
322            1 => [local[1], 1.0 - local[0]],
323            2 => [1.0 - local[0], 1.0 - local[1]],
324            3 => [1.0 - local[1], local[0]],
325            _ => local,
326        };
327        if self.orientation & 4 != 0 {
328            source[0] = 1.0 - source[0];
329        }
330        let [origin_x, origin_y, width, height] = self.visible;
331        [
332            (origin_x + source[0] * width).clamp(origin_x + 0.5, origin_x + width - 0.5),
333            (origin_y + source[1] * height).clamp(origin_y + 0.5, origin_y + height - 0.5),
334        ]
335    }
336
337    /// `filter_grid`: samples per output pixel along each axis.
338    fn filter_grid(&self) -> [f32; 2] {
339        let mut extent = [self.visible[2], self.visible[3]];
340        if self.orientation & 1 != 0 {
341            extent.swap(0, 1);
342        }
343        let wanted = |extent: f32, drawn: f32| {
344            let footprint = extent / drawn;
345            if footprint > 2.0 {
346                (footprint - 0.0001).ceil().clamp(1.0, 8.0)
347            } else {
348                1.0
349            }
350        };
351        let wanted = [
352            wanted(extent[0], self.draw[2]),
353            wanted(extent[1], self.draw[3]),
354        ];
355        let budget = self.max_filter_samples.clamp(1.0, 64.0);
356        let columns = wanted[0].min(budget);
357        let rows = wanted[1].min((budget / columns).floor().max(1.0));
358        [columns, rows]
359    }
360
361    /// `filtered_stored` around `stored_sample`.
362    fn filtered(&self, destination: [f32; 2], sample: impl Fn([f32; 2]) -> [f32; 4]) -> [f32; 4] {
363        let grid = self.filter_grid();
364        #[allow(clippy::float_cmp)]
365        if grid == [1.0, 1.0] {
366            return sample(self.source_pixel_at(destination));
367        }
368        let mut sum = [0.0_f32; 4];
369        #[allow(clippy::cast_possible_truncation, clippy::cast_sign_loss)]
370        for row in 0..grid[1] as u32 {
371            for column in 0..grid[0] as u32 {
372                #[allow(clippy::cast_precision_loss)]
373                let offset = [
374                    (column as f32 + 0.5) / grid[0] - 0.5,
375                    (row as f32 + 0.5) / grid[1] - 0.5,
376                ];
377                let value = sample(
378                    self.source_pixel_at([destination[0] + offset[0], destination[1] + offset[1]]),
379                );
380                for channel in 0..4 {
381                    sum[channel] += value[channel];
382                }
383            }
384        }
385        let count = grid[0] * grid[1];
386        sum.map(|value| value / count)
387    }
388}
389
390/// Converts the presented picture's rows with `pixel`, a function of the
391/// output position, into a stored picture.
392fn present_rows(
393    renderer: &CpuRenderer,
394    size: RenderSize,
395    geometry: &Geometry,
396    pixel: impl Fn([f32; 2]) -> [f32; 4] + Sync + Send,
397) -> CpuImage {
398    let bounds = geometry.bounds(size);
399    if bounds.is_empty() {
400        return CpuImage::transparent(size);
401    }
402    let width = bounds.width as usize;
403    let mut pixels = vec![[0_u16; 4]; bounds.pixels()];
404    renderer
405        .workers
406        .bands(&mut pixels, width, |first_row, band| {
407            for (index, line) in band.chunks_mut(width).enumerate() {
408                #[allow(clippy::cast_possible_truncation, clippy::cast_precision_loss)]
409                let y = (bounds.y as usize + first_row + index) as f32 + 0.5;
410                for (offset, out) in line.iter_mut().enumerate() {
411                    #[allow(clippy::cast_precision_loss)]
412                    let x = (bounds.x as usize + offset) as f32 + 0.5;
413                    let destination = [x, y];
414                    *out = if geometry.inside(destination) {
415                        half::store4(pixel(destination))
416                    } else {
417                        [0; 4]
418                    };
419                }
420            }
421        });
422    CpuImage::new(size, bounds, Pixels::Half(pixels))
423}
424
425/// One plane's samples, read as a sampler with clamp-to-edge addressing
426/// reads a texture of them: 8-bit codes as `code / 255`, 16-bit containers
427/// as `container / 65535`.
428struct Plane<'a> {
429    bytes: &'a [u8],
430    stride: usize,
431    width: u32,
432    height: u32,
433    wide: bool,
434    /// Samples per texel: 2 for interleaved chroma.
435    components: usize,
436}
437
438impl Plane<'_> {
439    #[inline]
440    fn texel(&self, x: i64, y: i64, component: usize) -> f32 {
441        #[allow(clippy::cast_possible_truncation, clippy::cast_sign_loss)]
442        let (x, y) = (
443            x.clamp(0, i64::from(self.width) - 1) as usize,
444            y.clamp(0, i64::from(self.height) - 1) as usize,
445        );
446        let sample = x * self.components + component;
447        if self.wide {
448            let offset = y * self.stride + sample * 2;
449            f32::from(u16::from_le_bytes([
450                self.bytes[offset],
451                self.bytes[offset + 1],
452            ])) / 65535.0
453        } else {
454            half::unorm8_value(self.bytes[y * self.stride + sample])
455        }
456    }
457
458    /// A bilinear sample at normalized position `position`.
459    #[inline]
460    fn sample(&self, position: [f32; 2], component: usize) -> f32 {
461        #[allow(clippy::cast_precision_loss)]
462        let texel = [
463            position[0] * self.width as f32 - 0.5,
464            position[1] * self.height as f32 - 0.5,
465        ];
466        let origin = [texel[0].floor(), texel[1].floor()];
467        let weight = [texel[0] - origin[0], texel[1] - origin[1]];
468        #[allow(clippy::cast_possible_truncation)]
469        let (x, y) = (origin[0] as i64, origin[1] as i64);
470        let top = self.texel(x, y, component)
471            + (self.texel(x + 1, y, component) - self.texel(x, y, component)) * weight[0];
472        let bottom = self.texel(x, y + 1, component)
473            + (self.texel(x + 1, y + 1, component) - self.texel(x, y + 1, component)) * weight[0];
474        top + (bottom - top) * weight[1]
475    }
476}
477
478/// The sRGB EOTF of the presentation shader: clamped first.
479fn srgb_eotf(value: f32) -> f32 {
480    exact_srgb_to_linear(value.clamp(0.0, 1.0))
481}
482
483// SMPTE ST 2084 and ARIB STD-B67, as yuv-to-linear.wesl computes them in
484// `f32`, with `libm` so every target computes the same.
485const PQ_M1: f32 = 0.159_301_76;
486const PQ_M2: f32 = 78.843_75;
487const PQ_C1: f32 = 0.835_937_5;
488const PQ_C2: f32 = 18.851_563;
489const PQ_C3: f32 = 18.6875;
490
491fn pq_eotf(code: f32) -> f32 {
492    let e = libm::powf(code.clamp(0.0, 1.0).max(1.0e-10), 1.0 / PQ_M2);
493    let y = libm::powf((e - PQ_C1).max(0.0) / (PQ_C2 - PQ_C3 * e), 1.0 / PQ_M1);
494    y * 10_000.0
495}
496
497fn pq_inverse_eotf(nits: f32) -> f32 {
498    let y = libm::powf((nits / 10_000.0).clamp(0.0, 1.0).max(1.0e-10), PQ_M1);
499    libm::powf((PQ_C1 + PQ_C2 * y) / (1.0 + PQ_C3 * y), PQ_M2)
500}
501
502fn hlg_inverse_oetf(code: f32) -> f32 {
503    let e = code.clamp(0.0, 1.0);
504    if e <= 0.5 {
505        return e * e / 3.0;
506    }
507    (libm::expf((e - 0.559_910_7) / 0.178_832_77) + 0.284_668_92) / 12.0
508}
509
510impl YuvConversion {
511    fn bt2390_eetf(&self, nits: f32) -> f32 {
512        let source = self.tone[2];
513        let value = pq_inverse_eotf(nits.min(self.tone[0])) / source;
514        let max_lum = self.tone[3] / source;
515        let knee = 1.5 * max_lum - 0.5;
516        let mut mapped = value;
517        if value >= knee && knee < 1.0 {
518            let t = (value - knee) / (1.0 - knee);
519            let t2 = t * t;
520            let t3 = t2 * t;
521            mapped = (2.0 * t3 - 3.0 * t2 + 1.0) * knee
522                + (t3 - 2.0 * t2 + t) * (1.0 - knee)
523                + (-2.0 * t3 + 3.0 * t2) * max_lum;
524        }
525        pq_eotf(mapped * source)
526    }
527
528    fn to_bt709(&self, rgb: [f32; 3]) -> [f32; 3] {
529        self.gamut
530            .map(|row| row[0] * rgb[0] + row[1] * rgb[1] + row[2] * rgb[2])
531    }
532
533    fn hdr_to_working(&self, encoded: [f32; 3]) -> [f32; 3] {
534        let nits = if self.transfer == 2 {
535            let scene = encoded.map(hlg_inverse_oetf);
536            let luma = (0.2627 * scene[0] + 0.678 * scene[1] + 0.0593 * scene[2]).max(0.0);
537            let gain = self.tone[0] * libm::powf(luma.max(1.0e-10), self.hlg_gamma - 1.0);
538            scene.map(|value| value * gain)
539        } else {
540            encoded.map(pq_eotf)
541        };
542        let converted = self.to_bt709(nits);
543        let largest = converted[0].max(converted[1].max(converted[2]));
544        let scale = if largest > 0.0 {
545            self.bt2390_eetf(largest) / largest
546        } else {
547            0.0
548        };
549        converted.map(|value| (value * scale / self.tone[1]).clamp(0.0, 1.0))
550    }
551
552    /// The YCbCr at a luma and a chroma position, decoded by the matrix and
553    /// range: sRGB-encoded R'G'B'.
554    fn encoded(&self, planes: &Planes<'_>, luma: [f32; 2], chroma: [f32; 2]) -> [f32; 3] {
555        let u = planes.u.sample(chroma, 0);
556        let v = if self.interleaved {
557            planes.u.sample(chroma, 1)
558        } else {
559            planes
560                .v
561                .as_ref()
562                .map_or(0.0, |plane| plane.sample(chroma, 0))
563        };
564        self.encoded_from([planes.y.sample(luma, 0), u, v])
565    }
566
567    /// Sampled codes, decoded by the range and the matrix: sRGB-encoded
568    /// R'G'B'.
569    #[inline]
570    fn encoded_from(&self, codes: [f32; 3]) -> [f32; 3] {
571        let yuv = [
572            codes[0] * self.range_scale[0] + self.range_offset[0],
573            codes[1] * self.range_scale[1] + self.range_offset[1],
574            codes[2] * self.range_scale[2] + self.range_offset[2],
575        ];
576        self.rows
577            .map(|row| row[0] * yuv[0] + row[1] * yuv[1] + row[2] * yuv[2])
578    }
579
580    /// `linear_rgb`.
581    fn linear_rgb(&self, planes: &Planes<'_>, luma: [f32; 2], chroma: [f32; 2]) -> [f32; 3] {
582        self.linear_from_encoded(self.encoded(planes, luma, chroma))
583    }
584
585    /// Encoded R'G'B' brought into the linear working space.
586    fn linear_from_encoded(&self, encoded: [f32; 3]) -> [f32; 3] {
587        if self.transfer != 0 {
588            return self.hdr_to_working(encoded.map(|value| value.clamp(0.0, 1.0)));
589        }
590        self.to_bt709(encoded.map(srgb_eotf))
591            .map(|value| value.clamp(0.0, 1.0))
592    }
593
594    /// `coordinates_at`: the luma and chroma positions of `source_pixel`.
595    fn coordinates(&self, source_pixel: [f32; 2]) -> ([f32; 2], [f32; 2]) {
596        let chroma_pixel = [
597            ((source_pixel[0] + self.chroma_offset[0]) * self.chroma_scale[0])
598                .clamp(self.chroma_window[0], self.chroma_window[2]),
599            ((source_pixel[1] + self.chroma_offset[1]) * self.chroma_scale[1])
600                .clamp(self.chroma_window[1], self.chroma_window[3]),
601        ];
602        (
603            [
604                source_pixel[0] / self.geometry.source[0],
605                source_pixel[1] / self.geometry.source[1],
606            ],
607            [
608                chroma_pixel[0] / self.chroma_size[0],
609                chroma_pixel[1] / self.chroma_size[1],
610            ],
611        )
612    }
613
614    fn alpha_at(&self, planes: &Planes<'_>, luma: [f32; 2]) -> f32 {
615        planes.alpha.as_ref().map_or(1.0, |plane| {
616            (plane.sample(luma, 0) * self.alpha_scale).clamp(0.0, 1.0)
617        })
618    }
619
620    /// `premultiplied_linear`: straight alpha premultiplied before it is
621    /// filtered.
622    fn premultiplied_linear(&self, planes: &Planes<'_>, source_pixel: [f32; 2]) -> [f32; 4] {
623        let (luma, chroma) = self.coordinates(source_pixel);
624        let texel = [
625            luma[0] * self.geometry.source[0] - 0.5,
626            luma[1] * self.geometry.source[1] - 0.5,
627        ];
628        let origin = [texel[0].floor(), texel[1].floor()];
629        let weight = [texel[0] - origin[0], texel[1] - origin[1]];
630        let first = [self.geometry.visible[0], self.geometry.visible[1]];
631        let last = [
632            first[0] + self.geometry.visible[2] - 1.0,
633            first[1] + self.geometry.visible[3] - 1.0,
634        ];
635        let corner = |dx: f32, dy: f32| {
636            self.coordinates([
637                (origin[0] + dx).clamp(first[0], last[0]) + 0.5,
638                (origin[1] + dy).clamp(first[1], last[1]) + 0.5,
639            ])
640        };
641        let corners = [
642            corner(0.0, 0.0),
643            corner(1.0, 0.0),
644            corner(0.0, 1.0),
645            corner(1.0, 1.0),
646        ];
647        let alphas = corners.map(|(luma, _)| self.alpha_at(planes, luma));
648        if alphas.iter().all(|alpha| *alpha >= 1.0) {
649            let rgb = self.linear_rgb(planes, luma, chroma);
650            return [rgb[0], rgb[1], rgb[2], 1.0];
651        }
652        let texels: Vec<[f32; 4]> = corners
653            .iter()
654            .zip(alphas)
655            .map(|((luma, chroma), alpha)| {
656                let rgb = self.linear_rgb(planes, *luma, *chroma);
657                [rgb[0] * alpha, rgb[1] * alpha, rgb[2] * alpha, alpha]
658            })
659            .collect();
660        let mix = |a: [f32; 4], b: [f32; 4], t: f32| {
661            [0, 1, 2, 3].map(|channel| a[channel] * (1.0 - t) + b[channel] * t)
662        };
663        mix(
664            mix(texels[0], texels[1], weight[0]),
665            mix(texels[2], texels[3], weight[0]),
666            weight[1],
667        )
668    }
669
670    /// `stored_sample`: one position as the intermediate stores every
671    /// presented source, sRGB-encoded, then premultiplied.
672    fn stored_sample(&self, planes: &Planes<'_>, source_pixel: [f32; 2]) -> [f32; 4] {
673        if !self.alpha_plane {
674            let (luma, chroma) = self.coordinates(source_pixel);
675            return self.stored_opaque(self.encoded(planes, luma, chroma));
676        }
677        Self::stored_linear(self.premultiplied_linear(planes, source_pixel))
678    }
679
680    /// An opaque sample's encoded R'G'B' as the intermediate stores it.
681    #[inline]
682    fn stored_opaque(&self, encoded: [f32; 3]) -> [f32; 4] {
683        if self.encoded_identity {
684            return [
685                encoded[0].clamp(0.0, 1.0),
686                encoded[1].clamp(0.0, 1.0),
687                encoded[2].clamp(0.0, 1.0),
688                1.0,
689            ];
690        }
691        let rgb = self.linear_from_encoded(encoded);
692        Self::stored_linear([rgb[0], rgb[1], rgb[2], 1.0])
693    }
694
695    /// Premultiplied linear light as the intermediate stores it.
696    fn stored_linear(linear: [f32; 4]) -> [f32; 4] {
697        if linear[3] <= 0.0 {
698            return [0.0; 4];
699        }
700        let alpha = linear[3];
701        [
702            encode_srgb(linear[0] / alpha) * alpha,
703            encode_srgb(linear[1] / alpha) * alpha,
704            encode_srgb(linear[2] / alpha) * alpha,
705            alpha,
706        ]
707    }
708}
709
710/// `encode_srgb` of yuv-to-linear.wesl: its input is already clamped.
711fn encode_srgb(value: f32) -> f32 {
712    linear_to_srgb(value)
713}
714
715struct Planes<'a> {
716    y: Plane<'a>,
717    u: Plane<'a>,
718    v: Option<Plane<'a>>,
719    alpha: Option<Plane<'a>>,
720}
721
722fn present_yuv(
723    renderer: &CpuRenderer,
724    frame: &YuvFrame<'_>,
725    conversion: &YuvConversion,
726) -> CpuImage {
727    fn plane<'a>(
728        source: &SourcePlane<'a>,
729        (width, height): (u32, u32),
730        wide: bool,
731        components: usize,
732    ) -> Plane<'a> {
733        Plane {
734            bytes: source.bytes,
735            stride: source.stride as usize,
736            width,
737            height,
738            wide,
739            components,
740        }
741    }
742    #[allow(clippy::cast_possible_truncation, clippy::cast_sign_loss)]
743    let output = RenderSize {
744        width: conversion.geometry.output[0] as u32,
745        height: conversion.geometry.output[1] as u32,
746    };
747    if conversion.skipped {
748        return CpuImage::transparent(output);
749    }
750    let format = conversion.format;
751    let wide = format.bytes_per_sample() == 2;
752    let (chroma_width, chroma_height) = format.chroma_size(frame.size.width, frame.size.height);
753    let planes = Planes {
754        y: plane(
755            &frame.planes[0],
756            (frame.size.width, frame.size.height),
757            wide,
758            1,
759        ),
760        u: plane(
761            &frame.planes[1],
762            (chroma_width, chroma_height),
763            wide,
764            if format.interleaved_chroma { 2 } else { 1 },
765        ),
766        v: (!format.interleaved_chroma)
767            .then(|| plane(&frame.planes[2], (chroma_width, chroma_height), wide, 1)),
768        alpha: frame
769            .alpha_plane
770            .as_ref()
771            .map(|alpha| plane(alpha, (frame.size.width, frame.size.height), wide, 1)),
772    };
773    let geometry = conversion.geometry;
774    let alpha = conversion.alpha;
775    if let Some(image) = present_separable(renderer, output, &planes, conversion) {
776        return image;
777    }
778    present_rows(renderer, output, &geometry, |destination| {
779        geometry
780            .filtered(destination, |source_pixel| {
781                conversion.stored_sample(&planes, source_pixel)
782            })
783            .map(|value| value * alpha)
784    })
785}
786
787/// Each output column's sample of `values`, a plane row filtered
788/// vertically: the two texels its tap names, filtered between.
789fn lerp_into(values: &[f32], taps: &[(usize, usize, f32)], out: &mut [f32]) {
790    for (out, &(first, second, weight)) in out.iter_mut().zip(taps) {
791        let a = values[first];
792        *out = a + (values[second] - a) * weight;
793    }
794}
795
796/// Where one output column (or row) of an upright presentation reads its
797/// planes: the luma and chroma texels either side and the weight between.
798#[derive(Clone, Copy, Default)]
799struct AxisTap {
800    inside: bool,
801    luma: (u32, u32, f32),
802    chroma: (u32, u32, f32),
803}
804
805impl YuvConversion {
806    /// The taps of output position `destination` along `axis` (0 for x),
807    /// computed with exactly the arithmetic of `source_pixel_at`,
808    /// `coordinates_at` and a bilinear sample, which work per axis when the
809    /// presentation turns by a half turn or not at all.
810    fn axis_tap(&self, axis: usize, destination: f32, luma_size: u32, chroma_size: u32) -> AxisTap {
811        let geometry = &self.geometry;
812        let inside = destination >= geometry.draw[axis]
813            && destination < geometry.draw[axis] + geometry.draw[axis + 2];
814        let local = (destination - geometry.draw[axis]) / geometry.draw[axis + 2];
815        let mut source = if geometry.orientation & 3 == 2 {
816            1.0 - local
817        } else {
818            local
819        };
820        if axis == 0 && geometry.orientation & 4 != 0 {
821            source = 1.0 - source;
822        }
823        let origin = geometry.visible[axis];
824        let extent = geometry.visible[axis + 2];
825        let source_pixel = (origin + source * extent).clamp(origin + 0.5, origin + extent - 0.5);
826        let chroma_pixel = ((source_pixel + self.chroma_offset[axis]) * self.chroma_scale[axis])
827            .clamp(self.chroma_window[axis], self.chroma_window[axis + 2]);
828        let taps = |position: f32, size: u32| {
829            #[allow(clippy::cast_precision_loss)]
830            let texel = position * size as f32 - 0.5;
831            let floor = texel.floor();
832            let last = i64::from(size) - 1;
833            #[allow(clippy::cast_possible_truncation)]
834            let first = floor as i64;
835            #[allow(clippy::cast_possible_truncation, clippy::cast_sign_loss)]
836            (
837                first.clamp(0, last) as u32,
838                (first + 1).clamp(0, last) as u32,
839                texel - floor,
840            )
841        };
842        AxisTap {
843            inside,
844            luma: taps(source_pixel / geometry.source[axis], luma_size),
845            chroma: taps(chroma_pixel / self.chroma_size[axis], chroma_size),
846        }
847    }
848}
849
850impl Plane<'_> {
851    /// Sample `component` of texel `x` in row `y`, normalized.
852    #[inline]
853    fn at(&self, x: u32, y: u32, component: usize) -> f32 {
854        let sample = x as usize * self.components + component;
855        let row = y as usize * self.stride;
856        if self.wide {
857            let offset = row + sample * 2;
858            f32::from(u16::from_le_bytes([
859                self.bytes[offset],
860                self.bytes[offset + 1],
861            ])) / 65535.0
862        } else {
863            half::unorm8_value(self.bytes[row + sample])
864        }
865    }
866
867    /// `component` of the texels `first..` of the rows `row` names,
868    /// filtered between them, into `out`.
869    fn vertical(&self, row: (u32, u32, f32), first: u32, component: usize, out: &mut [f32]) {
870        if !self.wide {
871            let count = out.len();
872            let line = |y: u32| {
873                let start = y as usize * self.stride + first as usize * self.components + component;
874                self.bytes[start..]
875                    .iter()
876                    .step_by(self.components)
877                    .take(count)
878                    .map(|&code| half::unorm8_value(code))
879            };
880            if row.2 == 0.0 {
881                for (value, top) in out.iter_mut().zip(line(row.0)) {
882                    *value = top;
883                }
884            } else {
885                for ((value, top), bottom) in out.iter_mut().zip(line(row.0)).zip(line(row.1)) {
886                    *value = top + (bottom - top) * row.2;
887                }
888            }
889            return;
890        }
891        for (offset, value) in out.iter_mut().enumerate() {
892            #[allow(clippy::cast_possible_truncation)]
893            let x = first + offset as u32;
894            let top = self.at(x, row.0, component);
895            *value = if row.2 == 0.0 {
896                top
897            } else {
898                top + (self.at(x, row.1, component) - top) * row.2
899            };
900        }
901    }
902}
903
904/// The presentation of an upright (or half-turned) source with no alpha
905/// plane and no minification grid, by output rows: each column's and each
906/// row's taps are worked out once, each output row's planes are filtered
907/// vertically once, and every pixel then filters horizontally between them.
908/// The same taps as the per-pixel path, summed in the other order; `None`
909/// for any other presentation.
910#[allow(clippy::too_many_lines)]
911fn present_separable(
912    renderer: &CpuRenderer,
913    output: RenderSize,
914    planes: &Planes<'_>,
915    conversion: &YuvConversion,
916) -> Option<CpuImage> {
917    let geometry = &conversion.geometry;
918    if conversion.alpha_plane || geometry.orientation & 1 != 0 || {
919        #[allow(clippy::float_cmp)]
920        let unfiltered = geometry.filter_grid() == [1.0, 1.0];
921        !unfiltered
922    } {
923        return None;
924    }
925    let bounds = geometry.bounds(output);
926    if bounds.is_empty() {
927        return Some(CpuImage::transparent(output));
928    }
929    #[allow(clippy::cast_precision_loss)]
930    let columns = (bounds.x..bounds.right())
931        .map(|x| conversion.axis_tap(0, x as f32 + 0.5, planes.y.width, planes.u.width))
932        .collect::<Vec<_>>();
933    let all_inside = columns.iter().all(|column| column.inside);
934    // The columns' taps relative to the first texel each plane's row holds.
935    let (luma_base, chroma_base) = (
936        columns.iter().map(|tap| tap.luma.0).min().unwrap_or(0),
937        columns.iter().map(|tap| tap.chroma.0).min().unwrap_or(0),
938    );
939    let relative = |(first, second, weight): (u32, u32, f32), base: u32| {
940        ((first - base) as usize, (second - base) as usize, weight)
941    };
942    let luma_taps = columns
943        .iter()
944        .map(|tap| relative(tap.luma, luma_base))
945        .collect::<Vec<_>>();
946    let chroma_taps = columns
947        .iter()
948        .map(|tap| relative(tap.chroma, chroma_base))
949        .collect::<Vec<_>>();
950    let alpha = conversion.alpha;
951    let width = bounds.width as usize;
952    // The texels the columns read, on each plane.
953    let reach = |pick: fn(&AxisTap) -> (u32, u32, f32)| {
954        let first = columns.iter().map(|tap| pick(tap).0).min().unwrap_or(0);
955        let last = columns.iter().map(|tap| pick(tap).1).max().unwrap_or(0);
956        (first, last)
957    };
958    let (luma_first, luma_last) = reach(|tap| tap.luma);
959    let (chroma_first, chroma_last) = reach(|tap| tap.chroma);
960    let mut pixels = vec![[0_u16; 4]; bounds.pixels()];
961    renderer
962        .workers
963        .bands(&mut pixels, width, |first_row, band| {
964            // One output row's planes filtered vertically, then each pixel
965            // filtered horizontally between them.
966            let mut luma = vec![0.0_f32; (luma_last - luma_first + 1) as usize];
967            let chroma_len = (chroma_last - chroma_first + 1) as usize;
968            let mut u = vec![0.0_f32; chroma_len];
969            let mut v = vec![0.0_f32; chroma_len];
970            let (mut sample_y, mut sample_u, mut sample_v) =
971                (vec![0.0; width], vec![0.0; width], vec![0.0; width]);
972            for (index, line) in band.chunks_mut(width).enumerate() {
973                #[allow(clippy::cast_possible_truncation, clippy::cast_precision_loss)]
974                let y = (bounds.y as usize + first_row + index) as f32 + 0.5;
975                let row = conversion.axis_tap(1, y, planes.y.height, planes.u.height);
976                if !row.inside {
977                    continue;
978                }
979                planes.y.vertical(row.luma, luma_first, 0, &mut luma);
980                planes.u.vertical(row.chroma, chroma_first, 0, &mut u);
981                if conversion.interleaved {
982                    planes.u.vertical(row.chroma, chroma_first, 1, &mut v);
983                } else if let Some(plane) = planes.v.as_ref() {
984                    plane.vertical(row.chroma, chroma_first, 0, &mut v);
985                }
986                let between =
987                    |values: &[f32], (first, second, weight): (u32, u32, f32), base: u32| {
988                        let a = values[(first - base) as usize];
989                        a + (values[(second - base) as usize] - a) * weight
990                    };
991                if conversion.encoded_identity && all_inside {
992                    // The conversion inlined, a pass at a time over the row: the
993                    // three samples, then range, matrix and clamp, as stored.
994                    let [scale_y, scale_u, scale_v] = conversion.range_scale;
995                    let [offset_y, offset_u, offset_v] = conversion.range_offset;
996                    let [red, green, blue] = conversion.rows;
997                    let alpha_bits = half::store(alpha);
998                    lerp_into(&luma, &luma_taps, &mut sample_y);
999                    lerp_into(&u, &chroma_taps, &mut sample_u);
1000                    lerp_into(&v, &chroma_taps, &mut sample_v);
1001                    for (((out, y), cb), cr) in
1002                        line.iter_mut().zip(&sample_y).zip(&sample_u).zip(&sample_v)
1003                    {
1004                        let y = y * scale_y + offset_y;
1005                        let cb = cb * scale_u + offset_u;
1006                        let cr = cr * scale_v + offset_v;
1007                        let channel = |row: [f32; 3]| {
1008                            half::store(
1009                                (row[0] * y + row[1] * cb + row[2] * cr).clamp(0.0, 1.0) * alpha,
1010                            )
1011                        };
1012                        *out = [channel(red), channel(green), channel(blue), alpha_bits];
1013                    }
1014                    continue;
1015                }
1016                for (out, column) in line.iter_mut().zip(&columns) {
1017                    if !column.inside {
1018                        continue;
1019                    }
1020                    let codes = [
1021                        between(&luma, column.luma, luma_first),
1022                        between(&u, column.chroma, chroma_first),
1023                        between(&v, column.chroma, chroma_first),
1024                    ];
1025                    let stored = conversion.stored_opaque(conversion.encoded_from(codes));
1026                    *out = half::store4(stored.map(|value| value * alpha));
1027                }
1028            }
1029        });
1030    Some(CpuImage::new(output, bounds, Pixels::Half(pixels)))
1031}
1032
1033struct RgbaSource<'a> {
1034    bytes: &'a [u8],
1035    stride: usize,
1036    size: RenderSize,
1037    encoding: PresentedEncoding,
1038}
1039
1040impl RgbaSource<'_> {
1041    /// The texel at `(x, y)` with the edge repeated, as the texture holds
1042    /// it: 8-bit codes, or half floats for linear-light sources.
1043    fn texel(&self, x: i64, y: i64) -> [f32; 4] {
1044        #[allow(clippy::cast_possible_truncation, clippy::cast_sign_loss)]
1045        let (x, y) = (
1046            x.clamp(0, i64::from(self.size.width) - 1) as usize,
1047            y.clamp(0, i64::from(self.size.height) - 1) as usize,
1048        );
1049        if self.encoding == PresentedEncoding::Linear {
1050            let offset = y * self.stride + x * 8;
1051            std::array::from_fn(|channel| {
1052                half::load(u16::from_le_bytes([
1053                    self.bytes[offset + channel * 2],
1054                    self.bytes[offset + channel * 2 + 1],
1055                ]))
1056            })
1057        } else {
1058            let offset = y * self.stride + x * 4;
1059            std::array::from_fn(|channel| half::unorm8_value(self.bytes[offset + channel]))
1060        }
1061    }
1062
1063    /// `filtered_sample` of rgba-present.wesl: a bilinear sample as
1064    /// premultiplied color in the space the texture is filtered in.
1065    fn filtered_sample(&self, source_pixel: [f32; 2]) -> [f32; 4] {
1066        let texel = [source_pixel[0] - 0.5, source_pixel[1] - 0.5];
1067        let origin = [texel[0].floor(), texel[1].floor()];
1068        let weight = [texel[0] - origin[0], texel[1] - origin[1]];
1069        #[allow(clippy::cast_possible_truncation)]
1070        let (x, y) = (origin[0] as i64, origin[1] as i64);
1071        let corners = [
1072            self.texel(x, y),
1073            self.texel(x + 1, y),
1074            self.texel(x, y + 1),
1075            self.texel(x + 1, y + 1),
1076        ];
1077        let mix = |a: [f32; 4], b: [f32; 4], t: f32| {
1078            [0, 1, 2, 3].map(|channel| a[channel] + (b[channel] - a[channel]) * t)
1079        };
1080        let bilinear = |texels: &[[f32; 4]; 4]| {
1081            mix(
1082                mix(texels[0], texels[1], weight[0]),
1083                mix(texels[2], texels[3], weight[0]),
1084                weight[1],
1085            )
1086        };
1087        if self.encoding != PresentedEncoding::Srgb(SourceAlpha::Straight) {
1088            return bilinear(&corners);
1089        }
1090        // Straight alpha is premultiplied before it is filtered.
1091        let premultiplied = corners.map(|texel| {
1092            [
1093                texel[0] * texel[3],
1094                texel[1] * texel[3],
1095                texel[2] * texel[3],
1096                texel[3],
1097            ]
1098        });
1099        if premultiplied.iter().all(|texel| texel[3] >= 1.0) {
1100            let source = bilinear(&corners);
1101            return [
1102                source[0] * source[3],
1103                source[1] * source[3],
1104                source[2] * source[3],
1105                source[3],
1106            ];
1107        }
1108        bilinear(&premultiplied)
1109    }
1110
1111    /// `stored_sample`.
1112    fn stored_sample(&self, source_pixel: [f32; 2]) -> [f32; 4] {
1113        let filtered = self.filtered_sample(source_pixel);
1114        if self.encoding != PresentedEncoding::Linear {
1115            return filtered;
1116        }
1117        if filtered[3] <= 0.0 {
1118            return [0.0; 4];
1119        }
1120        let alpha = filtered[3];
1121        [
1122            extended_linear_to_srgb(filtered[0] / alpha) * alpha,
1123            extended_linear_to_srgb(filtered[1] / alpha) * alpha,
1124            extended_linear_to_srgb(filtered[2] / alpha) * alpha,
1125            alpha,
1126        ]
1127    }
1128}
1129
1130fn present_rgba(renderer: &CpuRenderer, source: &RgbaSource<'_>, geometry: &Geometry) -> CpuImage {
1131    #[allow(clippy::cast_possible_truncation, clippy::cast_sign_loss)]
1132    let output = RenderSize {
1133        width: geometry.output[0] as u32,
1134        height: geometry.output[1] as u32,
1135    };
1136    present_rows(renderer, output, geometry, |destination| {
1137        geometry.filtered(destination, |source_pixel| {
1138            source.stored_sample(source_pixel)
1139        })
1140    })
1141}
1142
1143impl CpuRenderer {
1144    /// A source showing decoded YCbCr `planes` as `presentation` places
1145    /// them, converted the first time a graph draws it: the CPU counterpart
1146    /// of [`crate::Renderer::prepare_yuv_presented`].
1147    ///
1148    /// # Errors
1149    /// Returns [`RenderError::InvalidImageData`] for an unsupported format
1150    /// or planes that do not hold the frame, and
1151    /// [`RenderError::UnsupportedHdr`] when [`ColorPolicy::RejectHdr`] meets
1152    /// an HDR source.
1153    pub fn present_yuv(
1154        &self,
1155        planes: Arc<dyn YuvPlanes>,
1156        presentation: SourcePresentation,
1157        policy: ColorPolicy,
1158    ) -> Result<(CpuSource, Vec<ColorDiagnostic>), RenderError> {
1159        let frame = planes.frame();
1160        let mut diagnostics = Vec::new();
1161        let color = frame.color.unwrap_or_else(|| {
1162            diagnostics.push(ColorDiagnostic::MissingMetadataFallback);
1163            ColorMetadata::UNDECLARED
1164        });
1165        let tone: Option<HdrToneMap> = match hdr_handling(color, policy, &mut diagnostics)? {
1166            HdrHandling::Sdr => None,
1167            HdrHandling::ToneMap(tone) => Some(tone),
1168            HdrHandling::Skip => {
1169                return Ok((
1170                    CpuSource::image(Arc::new(CpuImage::transparent(presentation.output_size))),
1171                    diagnostics,
1172                ));
1173            }
1174        };
1175        let format = frame.format;
1176        if !frame.alpha.is_finite()
1177            || !(0.0..=1.0).contains(&frame.alpha)
1178            || !format.is_supported()
1179            || format.alpha != frame.alpha_plane.is_some()
1180        {
1181            return Err(RenderError::InvalidImageData);
1182        }
1183        validate_presentation(frame.size, presentation)?;
1184        let (chroma_width, chroma_height) = format.chroma_size(frame.size.width, frame.size.height);
1185        let pairs = if format.interleaved_chroma { 2 } else { 1 };
1186        let specs = [
1187            (frame.size.width, frame.size.height, 1_u32),
1188            (chroma_width, chroma_height, pairs),
1189            (chroma_width, chroma_height, 1),
1190        ];
1191        for (plane, (width, height, samples)) in frame
1192            .planes
1193            .iter()
1194            .zip(specs)
1195            .take(format.color_plane_count())
1196            .chain(
1197                frame
1198                    .alpha_plane
1199                    .iter()
1200                    .map(|plane| (plane, (frame.size.width, frame.size.height, 1))),
1201            )
1202        {
1203            let row_bytes = width
1204                .checked_mul(samples)
1205                .and_then(|row| row.checked_mul(format.bytes_per_sample()))
1206                .ok_or(RenderError::InvalidImageData)?;
1207            crate::yuv::validate_plane(plane.bytes, plane.stride, row_bytes, height)?;
1208        }
1209        let storage = if format.bytes_per_sample() == 1 {
1210            PlaneStorage::Unorm8
1211        } else {
1212            PlaneStorage::Unorm16
1213        };
1214        #[allow(clippy::cast_precision_loss)]
1215        let bytes = uniform_bytes(
1216            color,
1217            tone,
1218            frame.alpha,
1219            frame.size,
1220            presentation,
1221            format,
1222            storage.code_scale(format),
1223            self.quality_controls().max_filter_samples as f32,
1224        );
1225        let conversion = Box::new(YuvConversion::from_uniform(&bytes, format));
1226        Ok((
1227            CpuSource::new(Content::Yuv { planes, conversion }),
1228            diagnostics,
1229        ))
1230    }
1231
1232    /// A source showing RGBA8 `pixels` of `size`, rows `stride` bytes
1233    /// apart, as `presentation` places them: the CPU counterpart of
1234    /// [`crate::Renderer::prepare_rgba_presented`]. A `stride` of zero means
1235    /// rows packed.
1236    ///
1237    /// # Errors
1238    /// Returns [`RenderError::InvalidImageData`] for pixels that do not hold
1239    /// `size` or an invalid presentation.
1240    pub fn present_rgba8(
1241        &self,
1242        pixels: Arc<dyn RgbaPixels>,
1243        size: RenderSize,
1244        stride: u32,
1245        presentation: SourcePresentation,
1246        alpha: SourceAlpha,
1247    ) -> Result<CpuSource, RenderError> {
1248        self.present_rgba(
1249            pixels,
1250            size,
1251            stride,
1252            4,
1253            presentation,
1254            PresentedEncoding::Srgb(alpha),
1255        )
1256    }
1257
1258    /// A source showing premultiplied linear-light RGBA as half-float bits,
1259    /// in BT.709 primaries with `1.0` at SDR white: the CPU counterpart of
1260    /// [`crate::Renderer::prepare_linear_presented`]. A `stride` of zero
1261    /// means rows packed.
1262    ///
1263    /// # Errors
1264    /// Returns [`RenderError::InvalidImageData`] for pixels that do not hold
1265    /// `size` or an invalid presentation.
1266    pub fn present_linear_rgba16f(
1267        &self,
1268        pixels: Arc<dyn RgbaPixels>,
1269        size: RenderSize,
1270        stride: u32,
1271        presentation: SourcePresentation,
1272    ) -> Result<CpuSource, RenderError> {
1273        self.present_rgba(
1274            pixels,
1275            size,
1276            stride,
1277            8,
1278            presentation,
1279            PresentedEncoding::Linear,
1280        )
1281    }
1282
1283    fn present_rgba(
1284        &self,
1285        pixels: Arc<dyn RgbaPixels>,
1286        size: RenderSize,
1287        stride: u32,
1288        bytes_per_pixel: u32,
1289        presentation: SourcePresentation,
1290        encoding: PresentedEncoding,
1291    ) -> Result<CpuSource, RenderError> {
1292        validate_presentation(size, presentation)?;
1293        let row_bytes = size
1294            .width
1295            .checked_mul(bytes_per_pixel)
1296            .ok_or(RenderError::InvalidImageData)?;
1297        let stride = if stride == 0 { row_bytes } else { stride };
1298        crate::yuv::validate_plane(pixels.plane().bytes, stride, row_bytes, size.height)?;
1299        #[allow(clippy::cast_precision_loss)]
1300        let bytes = presentation_uniform_bytes(
1301            size,
1302            presentation,
1303            encoding,
1304            self.quality_controls().max_filter_samples as f32,
1305        );
1306        Ok(CpuSource::new(Content::Rgba {
1307            pixels,
1308            stride,
1309            size,
1310            geometry: Geometry::from_uniform(&bytes),
1311            encoding,
1312        }))
1313    }
1314}