Skip to main content

ff_render/graph/
mod.rs

1#[cfg(feature = "wgpu")]
2mod graph_inner;
3
4use crate::nodes::RenderNodeCpu;
5
6#[cfg(feature = "wgpu")]
7use crate::error::RenderError;
8
9#[cfg(feature = "wgpu")]
10use crate::context::RenderContext;
11#[cfg(feature = "wgpu")]
12use crate::nodes::RenderNode;
13#[cfg(feature = "wgpu")]
14use std::sync::Arc;
15
16// RenderGraph
17
18/// Linear chain of render nodes executed in insertion order.
19///
20/// The CPU fallback path ([`process_cpu`](Self::process_cpu)) is always
21/// available and does not require the `wgpu` feature.  When the `wgpu` feature
22/// is enabled, [`process_gpu`](Self::process_gpu) runs every node on the GPU.
23///
24/// # Construction
25///
26/// ```ignore
27/// // GPU+CPU graph (wgpu feature):
28/// let ctx = Arc::new(RenderContext::init().await?);
29/// let graph = RenderGraph::new(Arc::clone(&ctx))
30///     .push(ColorGradeNode { brightness: 0.1, ..Default::default() });
31///
32/// // CPU-only graph (no wgpu feature needed):
33/// let graph = RenderGraph::new_cpu()
34///     .push_cpu(ColorGradeNode { brightness: 0.1, ..Default::default() });
35/// ```
36pub struct RenderGraph {
37    /// Nodes for the CPU fallback path only (added via `push_cpu`).
38    cpu_nodes: Vec<Box<dyn RenderNodeCpu>>,
39    #[cfg(feature = "wgpu")]
40    gpu_nodes: Vec<Box<dyn RenderNode>>,
41    /// `None` when constructed via `new_cpu` — `process_gpu` will return an error.
42    #[cfg(feature = "wgpu")]
43    ctx: Option<Arc<RenderContext>>,
44    /// Working texture format for the GPU pipeline. `Rgba8Unorm` by default;
45    /// [`with_pixel_format`](Self::with_pixel_format) promotes it to `Rgba16Float`
46    /// for high-bit-depth input so precision is not lost before any node runs.
47    #[cfg(feature = "wgpu")]
48    internal_format: wgpu::TextureFormat,
49}
50
51/// Select the working GPU texture format for a source pixel format: `Rgba16Float`
52/// for high-bit-depth (10/12-bit) input, `Rgba8Unorm` otherwise.
53#[cfg(feature = "wgpu")]
54#[must_use]
55pub(crate) fn select_texture_format(pf: ff_format::PixelFormat) -> wgpu::TextureFormat {
56    if pf.is_high_bit_depth() {
57        wgpu::TextureFormat::Rgba16Float
58    } else {
59        wgpu::TextureFormat::Rgba8Unorm
60    }
61}
62
63impl RenderGraph {
64    /// Create a GPU+CPU graph.
65    ///
66    /// Nodes added via [`push`](Self::push) run on the GPU and expose a CPU
67    /// fallback via [`RenderNodeCpu`].  Nodes added via
68    /// [`push_cpu`](Self::push_cpu) run on the CPU path only.
69    #[cfg(feature = "wgpu")]
70    #[must_use]
71    pub fn new(ctx: Arc<RenderContext>) -> Self {
72        Self {
73            cpu_nodes: Vec::new(),
74            gpu_nodes: Vec::new(),
75            ctx: Some(ctx),
76            internal_format: wgpu::TextureFormat::Rgba8Unorm,
77        }
78    }
79
80    /// Create a CPU-only graph (no GPU context required).
81    ///
82    /// [`process_gpu`](Self::process_gpu) returns [`RenderError::Composite`]
83    /// when called on a CPU-only graph. Use [`process_cpu`](Self::process_cpu)
84    /// instead.
85    #[must_use]
86    pub fn new_cpu() -> Self {
87        Self {
88            cpu_nodes: Vec::new(),
89            #[cfg(feature = "wgpu")]
90            gpu_nodes: Vec::new(),
91            #[cfg(feature = "wgpu")]
92            ctx: None,
93            #[cfg(feature = "wgpu")]
94            internal_format: wgpu::TextureFormat::Rgba8Unorm,
95        }
96    }
97
98    /// Set the source pixel format so the GPU pipeline runs at the matching
99    /// working precision: high-bit-depth (10/12-bit) input promotes every
100    /// internal texture to `Rgba16Float`; 8-bit input stays `Rgba8Unorm`.
101    ///
102    /// The chosen format flows through the texture-pool key and every
103    /// intermediate target. For `Rgba16Float`, drive the graph with a
104    /// high-bit-depth source node (e.g. [`YuvUploadNode::new_high_bit_depth`]);
105    /// [`process_gpu`](Self::process_gpu) then returns raw `Rgba16Float` texels
106    /// (8 bytes/pixel) rather than 8-bit RGBA.
107    ///
108    /// [`YuvUploadNode::new_high_bit_depth`]: crate::nodes::YuvUploadNode::new_high_bit_depth
109    #[cfg(feature = "wgpu")]
110    #[must_use]
111    pub fn with_pixel_format(mut self, pf: ff_format::PixelFormat) -> Self {
112        self.internal_format = select_texture_format(pf);
113        self
114    }
115
116    /// The working GPU texture format ([`Rgba8Unorm`] by default,
117    /// [`Rgba16Float`] after [`with_pixel_format`](Self::with_pixel_format) with a
118    /// high-bit-depth format). Lets a caller interpret the byte layout of the
119    /// buffer [`process_gpu`](Self::process_gpu) returns.
120    ///
121    /// [`Rgba8Unorm`]: wgpu::TextureFormat::Rgba8Unorm
122    /// [`Rgba16Float`]: wgpu::TextureFormat::Rgba16Float
123    #[cfg(feature = "wgpu")]
124    #[must_use]
125    pub fn internal_format(&self) -> wgpu::TextureFormat {
126        self.internal_format
127    }
128
129    /// Append a GPU+CPU node to the chain.
130    ///
131    /// The node must implement both [`RenderNode`] (GPU, `wgpu` feature only)
132    /// and [`RenderNodeCpu`] (CPU, always available) — the `RenderNode`
133    /// supertrait bound guarantees this.
134    #[cfg(feature = "wgpu")]
135    #[must_use]
136    pub fn push(mut self, node: impl RenderNode + 'static) -> Self {
137        self.gpu_nodes.push(Box::new(node));
138        self
139    }
140
141    /// Append a CPU-only node to the chain.
142    ///
143    /// CPU-only nodes participate in [`process_cpu`](Self::process_cpu) but
144    /// not in [`process_gpu`](Self::process_gpu).
145    ///
146    /// When the `wgpu` feature is not enabled, this is the only `push` method.
147    #[cfg(not(feature = "wgpu"))]
148    #[must_use]
149    pub fn push(mut self, node: impl RenderNodeCpu + 'static) -> Self {
150        self.cpu_nodes.push(Box::new(node));
151        self
152    }
153
154    /// Append a CPU-only node (available regardless of the `wgpu` feature).
155    #[must_use]
156    pub fn push_cpu(mut self, node: impl RenderNodeCpu + 'static) -> Self {
157        self.cpu_nodes.push(Box::new(node));
158        self
159    }
160
161    // Processing
162
163    /// Run the GPU pipeline: upload `rgba` → execute all GPU nodes → download result.
164    ///
165    /// Requires the `wgpu` feature and a GPU context (created via [`new`](Self::new)).
166    /// Returns [`RenderError::Composite`] if called on a CPU-only graph.
167    ///
168    /// `rgba` is the 8-bit source frame and the returned buffer is 8-bit RGBA by
169    /// default. After [`with_pixel_format`](Self::with_pixel_format) selects an
170    /// `Rgba16Float` working format, `rgba` is ignored (the graph is driven by a
171    /// high-bit-depth source node) and the returned buffer is raw `Rgba16Float`
172    /// texels (8 bytes/pixel); see [`internal_format`](Self::internal_format).
173    ///
174    /// # Errors
175    ///
176    /// Returns an error on GPU device failure or staging-buffer readback failure.
177    #[cfg(feature = "wgpu")]
178    pub fn process_gpu(&self, rgba: &[u8], w: u32, h: u32) -> Result<Vec<u8>, RenderError> {
179        let ctx = self.ctx.as_ref().ok_or_else(|| RenderError::Composite {
180            message: "process_gpu called on a CPU-only RenderGraph (no RenderContext)".to_string(),
181        })?;
182        graph_inner::run_gpu(&self.gpu_nodes, ctx, rgba, w, h, self.internal_format)
183    }
184
185    /// Run the GPU pipeline and return the composited frame as a GPU
186    /// [`TextureHandle`](crate::sink::TextureHandle), **without** a GPU-to-CPU
187    /// readback. Use this for zero-copy display; use [`process_gpu`](Self::process_gpu)
188    /// when the caller needs the pixels in system memory.
189    ///
190    /// The returned texture is owned by the caller (taken out of the pool) and
191    /// stays valid until dropped.
192    ///
193    /// # Errors
194    ///
195    /// Returns [`RenderError::Composite`] if called on a CPU-only graph, or on
196    /// GPU device failure.
197    #[cfg(feature = "wgpu")]
198    pub fn process_gpu_to_texture(
199        &self,
200        rgba: &[u8],
201        w: u32,
202        h: u32,
203    ) -> Result<crate::sink::TextureHandle, RenderError> {
204        let ctx = self.ctx.as_ref().ok_or_else(|| RenderError::Composite {
205            message: "process_gpu_to_texture called on a CPU-only RenderGraph (no RenderContext)"
206                .to_string(),
207        })?;
208        graph_inner::run_gpu_to_texture(&self.gpu_nodes, ctx, rgba, w, h, self.internal_format)
209    }
210
211    /// Run the CPU fallback pipeline: apply each node's `process_cpu` in order.
212    ///
213    /// Both CPU-only nodes (`push_cpu`) and GPU nodes (`push`, wgpu feature)
214    /// participate — GPU nodes expose a CPU path via the `RenderNodeCpu`
215    /// supertrait.
216    #[must_use]
217    pub fn process_cpu(&self, rgba: &[u8], w: u32, h: u32) -> Vec<u8> {
218        let mut out = rgba.to_vec();
219
220        for node in &self.cpu_nodes {
221            node.process_cpu(&mut out, w, h);
222        }
223
224        #[cfg(feature = "wgpu")]
225        for node in &self.gpu_nodes {
226            node.process_cpu(&mut out, w, h);
227        }
228
229        out
230    }
231
232    /// Applies `param` to every GPU node that takes it, returning how many did.
233    ///
234    /// The point is a *stateful* node: rebuilding the graph to change one parameter
235    /// would discard the state the node exists to carry (see
236    /// [`NodeParam`](crate::NodeParam)). A return of `0` means nothing in this graph
237    /// names that parameter, which is how a caller tells a reuse from a no-op.
238    #[cfg(feature = "wgpu")]
239    #[must_use]
240    pub fn set_param(&self, param: crate::NodeParam) -> usize {
241        self.gpu_nodes
242            .iter()
243            .filter(|node| node.set_param(param))
244            .count()
245    }
246}
247
248#[cfg(test)]
249mod tests {
250    use super::*;
251    use crate::nodes::ColorGradeNode;
252
253    #[test]
254    fn render_graph_empty_cpu_should_return_input_unchanged() {
255        let graph = RenderGraph::new_cpu();
256        let rgba = vec![100u8, 150, 200, 255];
257        let result = graph.process_cpu(&rgba, 1, 1);
258        assert_eq!(result, rgba, "empty graph must return input unchanged");
259    }
260
261    #[test]
262    fn render_graph_push_cpu_color_grade_should_brighten() {
263        let graph = RenderGraph::new_cpu().push_cpu(ColorGradeNode::new(0.5, 1.0, 1.0, 0.0, 0.0));
264        let rgba = vec![128u8, 128, 128, 255];
265        let result = graph.process_cpu(&rgba, 1, 1);
266        assert!(
267            result[0] > 128,
268            "brightness +0.5 must increase R; got {}",
269            result[0]
270        );
271    }
272
273    #[test]
274    fn render_graph_multiple_cpu_nodes_should_chain() {
275        // Two brightness boosts: +0.1 then +0.1 → total ≈ +0.2.
276        let graph = RenderGraph::new_cpu()
277            .push_cpu(ColorGradeNode::new(0.1, 1.0, 1.0, 0.0, 0.0))
278            .push_cpu(ColorGradeNode::new(0.1, 1.0, 1.0, 0.0, 0.0));
279        let single = RenderGraph::new_cpu().push_cpu(ColorGradeNode::new(0.2, 1.0, 1.0, 0.0, 0.0));
280
281        let rgba = vec![100u8, 100, 100, 255];
282        let chained = graph.process_cpu(&rgba, 1, 1);
283        let single_result = single.process_cpu(&rgba, 1, 1);
284
285        // Both should produce similar (but not necessarily identical) results.
286        let diff = (chained[0] as i32 - single_result[0] as i32).abs();
287        assert!(
288            diff <= 2,
289            "chained vs single brightness boost must be close; got chained={} single={}",
290            chained[0],
291            single_result[0]
292        );
293    }
294}
295
296#[cfg(all(test, feature = "wgpu"))]
297mod gpu_tests {
298    use super::{Arc, RenderContext, RenderGraph};
299    use crate::nodes::{ColorGradeNode, RenderNode, RenderNodeCpu};
300
301    /// A headless GPU context, or `None` when no adapter is available (CI).
302    fn ctx() -> Option<Arc<RenderContext>> {
303        match futures::executor::block_on(RenderContext::init()) {
304            Ok(ctx) => Some(Arc::new(ctx)),
305            Err(_) => None,
306        }
307    }
308
309    /// Fill a whole texture with a solid RGBA color via `write_texture`.
310    fn fill(ctx: &RenderContext, tex: &wgpu::Texture, color: [u8; 4]) {
311        let (w, h) = (tex.width(), tex.height());
312        let data: Vec<u8> = color
313            .iter()
314            .copied()
315            .cycle()
316            .take((w * h * 4) as usize)
317            .collect();
318        ctx.queue.write_texture(
319            wgpu::TexelCopyTextureInfo {
320                texture: tex,
321                mip_level: 0,
322                origin: wgpu::Origin3d::ZERO,
323                aspect: wgpu::TextureAspect::All,
324            },
325            &data,
326            wgpu::TexelCopyBufferLayout {
327                offset: 0,
328                bytes_per_row: Some(w * 4),
329                rows_per_image: None,
330            },
331            wgpu::Extent3d {
332                width: w,
333                height: h,
334                depth_or_array_layers: 1,
335            },
336        );
337    }
338
339    const COLOR_A: [u8; 4] = [10, 20, 30, 255];
340    const COLOR_B: [u8; 4] = [200, 150, 100, 255];
341    const GREEN: [u8; 4] = [0, 255, 0, 255];
342    const RED: [u8; 4] = [255, 0, 0, 255];
343
344    /// Two-pass node: writes COLOR_A into the first pass and COLOR_B into the
345    /// second. If the executor allocated only one output (ignoring `pass_count`)
346    /// it writes COLOR_A instead, so a readback of COLOR_B proves both passes ran.
347    struct TwoPassNode;
348    impl RenderNodeCpu for TwoPassNode {
349        fn process_cpu(&self, _rgba: &mut [u8], _w: u32, _h: u32) {}
350    }
351    impl RenderNode for TwoPassNode {
352        fn pass_count(&self) -> usize {
353            2
354        }
355        fn process(
356            &self,
357            _inputs: &[&wgpu::Texture],
358            outputs: &[&wgpu::Texture],
359            ctx: &RenderContext,
360        ) {
361            if outputs.len() >= 2 {
362                fill(ctx, outputs[0], COLOR_A);
363                fill(ctx, outputs[1], COLOR_B);
364            } else {
365                fill(ctx, outputs[0], COLOR_A);
366            }
367        }
368    }
369
370    /// Two-input node: writes GREEN when it receives both inputs, RED otherwise.
371    /// A readback of GREEN proves the executor passed `input_count()` inputs.
372    struct TwoInputNode;
373    impl RenderNodeCpu for TwoInputNode {
374        fn process_cpu(&self, _rgba: &mut [u8], _w: u32, _h: u32) {}
375    }
376    impl RenderNode for TwoInputNode {
377        fn input_count(&self) -> usize {
378            2
379        }
380        fn process(
381            &self,
382            inputs: &[&wgpu::Texture],
383            outputs: &[&wgpu::Texture],
384            ctx: &RenderContext,
385        ) {
386            let color = if inputs.len() == 2 { GREEN } else { RED };
387            fill(ctx, outputs[0], color);
388        }
389    }
390
391    #[test]
392    fn executor_should_run_a_two_pass_node_and_read_back_the_final_pass() {
393        let Some(ctx) = ctx() else {
394            return;
395        };
396        let graph = RenderGraph::new(Arc::clone(&ctx)).push(TwoPassNode);
397        let (w, h) = (16u32, 16u32);
398        let rgba = vec![0u8; (w * h * 4) as usize];
399
400        let out = graph.process_gpu(&rgba, w, h).expect("two-pass frame");
401        assert_eq!(
402            &out[0..4],
403            &COLOR_B,
404            "the final pass (COLOR_B) must be read back; got {:?}",
405            &out[0..4]
406        );
407    }
408
409    #[test]
410    fn executor_should_feed_two_inputs_to_a_multi_input_node() {
411        let Some(ctx) = ctx() else {
412            return;
413        };
414        let graph = RenderGraph::new(Arc::clone(&ctx)).push(TwoInputNode);
415        let (w, h) = (16u32, 16u32);
416        let rgba = vec![0u8; (w * h * 4) as usize];
417
418        let out = graph.process_gpu(&rgba, w, h).expect("two-input frame");
419        assert_eq!(
420            &out[0..4],
421            &GREEN,
422            "receiving two inputs must produce GREEN; got {:?}",
423            &out[0..4]
424        );
425    }
426
427    fn alloc_count(ctx: &RenderContext) -> usize {
428        ctx.pool
429            .lock()
430            .unwrap_or_else(std::sync::PoisonError::into_inner)
431            .alloc_count()
432    }
433
434    #[test]
435    fn render_graph_should_not_allocate_textures_after_the_first_frame() {
436        let Some(ctx) = ctx() else {
437            return;
438        };
439        // Identity grade: a real GPU node so run_gpu acquires input + output.
440        let graph =
441            RenderGraph::new(Arc::clone(&ctx)).push(ColorGradeNode::new(0.0, 1.0, 1.0, 0.0, 0.0));
442        let (w, h) = (16u32, 16u32);
443        let rgba = vec![128u8; (w * h * 4) as usize];
444
445        graph.process_gpu(&rgba, w, h).expect("first frame");
446        let after_first = alloc_count(&ctx);
447        assert!(
448            after_first > 0,
449            "the first frame must allocate its textures; got {after_first}"
450        );
451
452        for _ in 0..3 {
453            graph.process_gpu(&rgba, w, h).expect("subsequent frame");
454        }
455        assert_eq!(
456            alloc_count(&ctx),
457            after_first,
458            "same-size frames must reuse pooled textures (steady state = 0 allocations/frame)"
459        );
460    }
461
462    #[test]
463    fn process_gpu_to_texture_should_return_handle_of_input_dimensions() {
464        let Some(ctx) = ctx() else {
465            return;
466        };
467        let graph =
468            RenderGraph::new(Arc::clone(&ctx)).push(ColorGradeNode::new(0.0, 1.0, 1.0, 0.0, 0.0));
469        let (w, h) = (16u32, 16u32);
470        let rgba = vec![128u8; (w * h * 4) as usize];
471
472        let handle = graph
473            .process_gpu_to_texture(&rgba, w, h)
474            .expect("texture handle");
475        assert_eq!(handle.width, w, "handle width must match input");
476        assert_eq!(handle.height, h, "handle height must match input");
477        assert_eq!(handle.texture.width(), w, "GPU texture width must match");
478        assert_eq!(handle.texture.height(), h, "GPU texture height must match");
479        assert_eq!(
480            ctx.readback_count(),
481            0,
482            "the texture path must not read back to system memory"
483        );
484    }
485
486    #[test]
487    fn scale_gpu_should_produce_requested_dimensions() {
488        use crate::nodes::{ScaleAlgorithm, ScaleNode};
489
490        let Some(ctx) = ctx() else {
491            return;
492        };
493        let (in_w, in_h) = (8u32, 8u32);
494        let (out_w, out_h) = (4u32, 2u32);
495        let graph = RenderGraph::new(Arc::clone(&ctx)).push(ScaleNode::new(
496            out_w,
497            out_h,
498            ScaleAlgorithm::Bilinear,
499        ));
500        let rgba = vec![128u8; (in_w * in_h * 4) as usize];
501
502        let handle = graph
503            .process_gpu_to_texture(&rgba, in_w, in_h)
504            .expect("scaled texture");
505        assert_eq!(
506            (handle.width, handle.height),
507            (out_w, out_h),
508            "handle must report the requested dimensions, not the input size"
509        );
510        assert_eq!(
511            (handle.texture.width(), handle.texture.height()),
512            (out_w, out_h),
513            "the GPU texture must be allocated at the requested dimensions"
514        );
515    }
516
517    #[test]
518    fn scale_gpu_downscale_solid_should_preserve_colour() {
519        use crate::nodes::{ScaleAlgorithm, ScaleNode};
520
521        let Some(ctx) = ctx() else {
522            return;
523        };
524        let (in_w, in_h) = (8u32, 8u32);
525        let (out_w, out_h) = (2u32, 2u32);
526        let mut rgba = Vec::new();
527        for _ in 0..(in_w * in_h) {
528            rgba.extend_from_slice(&[200, 100, 50, 255]);
529        }
530        let graph = RenderGraph::new(Arc::clone(&ctx)).push(ScaleNode::new(
531            out_w,
532            out_h,
533            ScaleAlgorithm::Bilinear,
534        ));
535
536        let out = graph
537            .process_gpu(&rgba, in_w, in_h)
538            .expect("downscaled bytes");
539        assert_eq!(
540            out.len(),
541            (out_w * out_h * 4) as usize,
542            "readback must be at the scaled size (proves the resize happened)"
543        );
544        for px in out.chunks_exact(4) {
545            assert!(
546                (i32::from(px[0]) - 200).abs() <= 4,
547                "R must be preserved through downscale; got {}",
548                px[0]
549            );
550            assert!(
551                (i32::from(px[1]) - 100).abs() <= 4,
552                "G must be preserved; got {}",
553                px[1]
554            );
555            assert!(
556                (i32::from(px[2]) - 50).abs() <= 4,
557                "B must be preserved; got {}",
558                px[2]
559            );
560        }
561    }
562
563    /// Decode an IEEE-754 half-float (as read back from an `Rgba16Float` target)
564    /// to `f32`. Adequate for the [0, 1] RGB values these tests read.
565    #[allow(clippy::cast_precision_loss)]
566    fn f16_to_f32(bits: u16) -> f32 {
567        let sign = if bits & 0x8000 != 0 { -1.0 } else { 1.0 };
568        let exp = i32::from((bits >> 10) & 0x1f);
569        let frac = f32::from(bits & 0x3ff);
570        if exp == 0 {
571            sign * frac * 2f32.powi(-24)
572        } else if exp == 0x1f {
573            sign * f32::INFINITY
574        } else {
575            sign * (1.0 + frac / 1024.0) * 2f32.powi(exp - 15)
576        }
577    }
578
579    /// A plane of `count` little-endian `u16` samples all equal to `value`.
580    fn plane10(value: u16, count: usize) -> Vec<u8> {
581        value
582            .to_le_bytes()
583            .iter()
584            .copied()
585            .cycle()
586            .take(count * 2)
587            .collect()
588    }
589
590    #[test]
591    fn pipeline_should_select_rgba16float_for_10bit_input() {
592        use super::select_texture_format;
593        use ff_format::PixelFormat;
594
595        assert_eq!(
596            select_texture_format(PixelFormat::Yuv420p10le),
597            wgpu::TextureFormat::Rgba16Float,
598            "10-bit planar input must select Rgba16Float"
599        );
600        assert_eq!(
601            select_texture_format(PixelFormat::P010le),
602            wgpu::TextureFormat::Rgba16Float,
603            "10-bit semi-planar input must select Rgba16Float"
604        );
605        assert_eq!(
606            select_texture_format(PixelFormat::Yuv420p),
607            wgpu::TextureFormat::Rgba8Unorm,
608            "8-bit input must stay Rgba8Unorm"
609        );
610        assert_eq!(
611            select_texture_format(PixelFormat::Rgba),
612            wgpu::TextureFormat::Rgba8Unorm,
613            "8-bit RGBA input must stay Rgba8Unorm"
614        );
615        // The builder reflects the same choice (no adapter required).
616        assert_eq!(
617            RenderGraph::new_cpu()
618                .with_pixel_format(PixelFormat::Yuv420p10le)
619                .internal_format(),
620            wgpu::TextureFormat::Rgba16Float
621        );
622        assert_eq!(
623            RenderGraph::new_cpu()
624                .with_pixel_format(PixelFormat::Yuv420p)
625                .internal_format(),
626            wgpu::TextureFormat::Rgba8Unorm
627        );
628    }
629
630    #[test]
631    fn yuv_upload_should_preserve_10bit_precision_into_rgba16float() {
632        use ff_format::PixelFormat;
633
634        use crate::nodes::{YuvFormat, YuvUploadNode};
635
636        let Some(ctx) = ctx() else {
637            return;
638        };
639        let (w, h) = (2u32, 2u32);
640
641        // Render one 10-bit luma value (neutral chroma) at Rgba16Float and return
642        // the read-back R channel as f32.
643        let render = |y10: u16| -> f32 {
644            let mut node = YuvUploadNode::new_high_bit_depth(YuvFormat::Yuv420p, w, h);
645            // 2×2 420p: 4 luma samples, 1 chroma sample; neutral chroma = 512.
646            node.set_planes(plane10(y10, 4), plane10(512, 1), plane10(512, 1));
647            let graph = RenderGraph::new(Arc::clone(&ctx))
648                .with_pixel_format(PixelFormat::Yuv420p10le)
649                .push(node);
650            // The 8-bit `rgba` arg is ignored for an Rgba16Float graph.
651            let out = graph.process_gpu(&[], w, h).expect("hdr frame");
652            assert_eq!(
653                out.len(),
654                (w * h * 8) as usize,
655                "Rgba16Float readback must be 8 bytes/pixel"
656            );
657            f16_to_f32(u16::from_le_bytes([out[0], out[1]]))
658        };
659
660        // Y = 512 and Y = 515 both round to 128 (0.502) in 8-bit, but differ by
661        // 3/1023 ≈ 0.0029 in 10-bit. Preserving that proves the pipeline ran at
662        // >8-bit precision (non-vacuous: an 8-bit path would make them identical).
663        let a = render(512);
664        let b = render(515);
665        assert!(
666            (a - 512.0 / 1023.0).abs() < 0.01,
667            "Y=512 must decode to ~0.5005; got {a}"
668        );
669        assert!(
670            (b - 515.0 / 1023.0).abs() < 0.01,
671            "Y=515 must decode to ~0.5034; got {b}"
672        );
673        assert!(
674            (b - a).abs() > 0.0015,
675            "10-bit precision must distinguish Y=512 from Y=515; got a={a} b={b}"
676        );
677    }
678
679    /// Bits P010 leaves zeroed at the bottom of each 16-bit sample.
680    const P010_SHIFT: u32 = 6;
681
682    #[test]
683    fn p010_upload_should_preserve_10bit_precision_into_rgba16float() {
684        use ff_format::PixelFormat;
685
686        use crate::nodes::YuvUploadNode;
687
688        let Some(ctx) = ctx() else {
689            return;
690        };
691        let (w, h) = (2u32, 2u32);
692
693        // Render one MSB-aligned 10-bit luma value (neutral chroma) at
694        // Rgba16Float and return the read-back R channel as f32.
695        let render = |y10: u16| -> f32 {
696            let mut node = YuvUploadNode::new_p010(w, h);
697            // 2×2 at 4:2:0: 4 luma samples and one chroma pixel, whose Cb and Cr
698            // are the two interleaved samples of the UV plane.
699            node.set_planes_semi_planar(
700                plane10(y10 << P010_SHIFT, 4),
701                plane10(512 << P010_SHIFT, 2),
702            );
703            let graph = RenderGraph::new(Arc::clone(&ctx))
704                .with_pixel_format(PixelFormat::P010le)
705                .push(node);
706            // The 8-bit `rgba` arg is ignored for an Rgba16Float graph.
707            let out = graph.process_gpu(&[], w, h).expect("hdr frame");
708            assert_eq!(
709                out.len(),
710                (w * h * 8) as usize,
711                "Rgba16Float readback must be 8 bytes/pixel"
712            );
713            f16_to_f32(u16::from_le_bytes([out[0], out[1]]))
714        };
715
716        // Same argument as the planar 10-bit test: Y = 512 and Y = 515 collapse
717        // onto the same 8-bit value but stay 3/1023 ≈ 0.0029 apart in 10-bit.
718        let a = render(512);
719        let b = render(515);
720        assert!(
721            (a - 512.0 / 1023.0).abs() < 0.01,
722            "P010 Y=512 must decode to ~0.5005; got {a}"
723        );
724        assert!(
725            (b - 515.0 / 1023.0).abs() < 0.01,
726            "P010 Y=515 must decode to ~0.5034; got {b}"
727        );
728        assert!(
729            (b - a).abs() > 0.0015,
730            "10-bit precision must distinguish Y=512 from Y=515; got a={a} b={b}"
731        );
732    }
733
734    #[test]
735    fn p010_upload_gpu_should_match_planar_10bit_upload() {
736        use ff_format::PixelFormat;
737
738        use crate::nodes::{YuvFormat, YuvUploadNode};
739
740        const Y: [u16; 8] = [200, 500, 800, 300, 900, 100, 600, 400];
741        const CB: [u16; 2] = [300, 700];
742        const CR: [u16; 2] = [800, 200];
743
744        let Some(ctx) = ctx() else {
745            return;
746        };
747        // 4×2 at 4:2:0 gives a 2×1 chroma plane, so the shader must read the two
748        // chroma columns at the right stride; the columns carry opposite Cb/Cr so
749        // a swapped de-interleave changes the result. The CPU tests cannot cover
750        // any of this — the shader is a separate implementation.
751        let (w, h) = (4u32, 2u32);
752
753        let samples = |values: &[u16], shift: u32| -> Vec<u8> {
754            values
755                .iter()
756                .flat_map(|v| (*v << shift).to_le_bytes())
757                .collect()
758        };
759        let decode = |out: &[u8]| -> Vec<f32> {
760            out.chunks_exact(2)
761                .map(|c| f16_to_f32(u16::from_le_bytes([c[0], c[1]])))
762                .collect()
763        };
764
765        let mut planar = YuvUploadNode::new_high_bit_depth(YuvFormat::Yuv420p, w, h);
766        planar.set_planes(samples(&Y, 0), samples(&CB, 0), samples(&CR, 0));
767        let expected = decode(
768            &RenderGraph::new(Arc::clone(&ctx))
769                .with_pixel_format(PixelFormat::Yuv420p10le)
770                .push(planar)
771                .process_gpu(&[], w, h)
772                .expect("planar hdr frame"),
773        );
774
775        let mut p010 = YuvUploadNode::new_p010(w, h);
776        p010.set_planes_semi_planar(
777            samples(&Y, P010_SHIFT),
778            samples(&[CB[0], CR[0], CB[1], CR[1]], P010_SHIFT),
779        );
780        let got = decode(
781            &RenderGraph::new(Arc::clone(&ctx))
782                .with_pixel_format(PixelFormat::P010le)
783                .push(p010)
784                .process_gpu(&[], w, h)
785                .expect("p010 hdr frame"),
786        );
787
788        assert_eq!(
789            got.len(),
790            expected.len(),
791            "both graphs must read back the same number of channels"
792        );
793        for (i, (g, e)) in got.iter().zip(&expected).enumerate() {
794            assert!(
795                (g - e).abs() < 0.002,
796                "channel {i} must match the planar path: p010={g} planar={e}"
797            );
798        }
799        // Non-vacuous: a de-interleave that returned a constant would satisfy the
800        // comparison if both paths were equally broken, so require the fixture to
801        // have actually driven the two chroma columns apart.
802        let red_col0 = got[0];
803        let red_col2 = got[2 * 4];
804        assert!(
805            (red_col0 - red_col2).abs() > 0.1,
806            "the chroma columns must differ in the output; got {red_col0} and {red_col2}"
807        );
808    }
809}