Skip to main content

pixelflow_test_support/
clips.rs

1//! In-memory synthetic clip sources for render tests.
2
3use std::sync::Arc;
4
5use pixelflow_core::{
6    Clip, ClipFormat, ClipMedia, ClipResolution, ErrorCategory, ErrorCode, Frame, FrameCount,
7    FrameExecutor, FrameRate, FrameRequest, Graph, GraphBuilder, PixelFlowError, Rational,
8    RenderExecutorMap, Result,
9};
10
11/// Renderable in-memory synthetic clip plus its executor map.
12pub struct SyntheticClip {
13    graph: Graph,
14    clip: Clip,
15    executors: RenderExecutorMap,
16}
17
18impl SyntheticClip {
19    /// Returns graph containing this synthetic source as final output.
20    #[must_use]
21    pub const fn graph(&self) -> &Graph {
22        &self.graph
23    }
24
25    /// Returns clip handle for synthetic source node.
26    #[must_use]
27    pub const fn clip(&self) -> Clip {
28        self.clip
29    }
30
31    /// Returns executor map needed to render synthetic source.
32    #[must_use]
33    pub fn executors(&self) -> RenderExecutorMap {
34        self.executors.clone()
35    }
36}
37
38struct FrameSequenceExecutor {
39    frames: Arc<[Frame]>,
40}
41
42impl FrameExecutor for FrameSequenceExecutor {
43    fn prepare(&self, request: FrameRequest<'_>) -> Result<Frame> {
44        self.frames
45            .get(request.frame_number())
46            .cloned()
47            .ok_or_else(|| {
48                PixelFlowError::new(
49                    ErrorCategory::Core,
50                    ErrorCode::new("render.frame_out_of_range"),
51                    format!("synthetic frame {} is out of range", request.frame_number()),
52                )
53            })
54    }
55}
56
57/// Builds graph source backed by in-memory frames.
58pub fn synthetic_clip_from_frames(
59    name: &str,
60    frames: Vec<Frame>,
61    frame_rate: Rational,
62) -> Result<SyntheticClip> {
63    let Some(first) = frames.first() else {
64        return Err(PixelFlowError::new(
65            ErrorCategory::Core,
66            ErrorCode::new("test.empty_synthetic_clip"),
67            "synthetic clip requires at least one frame",
68        ));
69    };
70
71    for frame in &frames {
72        if frame.format() != first.format()
73            || frame.width() != first.width()
74            || frame.height() != first.height()
75        {
76            return Err(PixelFlowError::new(
77                ErrorCategory::Core,
78                ErrorCode::new("test.inconsistent_synthetic_clip"),
79                "all synthetic clip frames must share format and dimensions",
80            ));
81        }
82    }
83
84    let media = ClipMedia::new(
85        ClipFormat::Fixed(first.format().clone()),
86        ClipResolution::Fixed {
87            width: first.width(),
88            height: first.height(),
89        },
90        FrameCount::Finite(frames.len()),
91        FrameRate::Cfr(frame_rate),
92    );
93    let mut builder = GraphBuilder::new();
94    let clip = builder.source(name, media);
95    builder.set_output(clip);
96    let graph = builder.build();
97
98    let mut executors = RenderExecutorMap::new();
99    executors.insert(
100        clip.node_id(),
101        Arc::new(FrameSequenceExecutor {
102            frames: Arc::from(frames),
103        }),
104    );
105
106    Ok(SyntheticClip {
107        graph,
108        clip,
109        executors,
110    })
111}
112
113#[cfg(test)]
114mod tests {
115    use pixelflow_core::{Rational, RenderEngine, RenderOptions, WorkerPoolConfig};
116
117    use crate::{
118        EXACT_GOLDEN_TOLERANCE, assert_plane_u8_near, synthetic_clip_from_frames,
119        synthetic_u8_frame,
120    };
121
122    #[test]
123    fn synthetic_clip_renders_in_memory_frames_without_media_files() {
124        let frames = vec![
125            synthetic_u8_frame("gray8", 2, 1, |_plane, x, _y| {
126                u8::try_from(x).expect("fixture sample fits u8")
127            })
128            .expect("frame 0"),
129            synthetic_u8_frame("gray8", 2, 1, |_plane, x, _y| {
130                u8::try_from(x + 10).expect("fixture sample fits u8")
131            })
132            .expect("frame 1"),
133        ];
134        let clip = synthetic_clip_from_frames(
135            "synthetic",
136            frames,
137            Rational {
138                numerator: 24,
139                denominator: 1,
140            },
141        )
142        .expect("clip should build");
143
144        let rendered = RenderEngine::new(WorkerPoolConfig::new(1))
145            .render_ordered(
146                clip.graph().clone(),
147                clip.executors(),
148                RenderOptions::new(0, None),
149            )
150            .expect("render starts")
151            .collect::<pixelflow_core::Result<Vec<_>>>()
152            .expect("render succeeds");
153
154        assert_eq!(rendered.len(), 2);
155        let first = rendered.first().expect("first frame exists");
156        let second = rendered.get(1).expect("second frame exists");
157        assert_plane_u8_near(first, 0, &[&[0, 1]], EXACT_GOLDEN_TOLERANCE);
158        assert_plane_u8_near(second, 0, &[&[10, 11]], EXACT_GOLDEN_TOLERANCE);
159    }
160
161    #[test]
162    fn synthetic_clip_rejects_empty_inputs() {
163        let Err(error) = synthetic_clip_from_frames(
164            "synthetic",
165            Vec::new(),
166            Rational {
167                numerator: 24,
168                denominator: 1,
169            },
170        ) else {
171            panic!("empty synthetic clip should fail");
172        };
173
174        assert_eq!(error.code().as_str(), "test.empty_synthetic_clip");
175    }
176
177    #[test]
178    fn synthetic_clip_rejects_mismatched_frame_shapes() {
179        let frames = vec![
180            synthetic_u8_frame("gray8", 2, 1, |_plane, _x, _y| 0).expect("frame 0"),
181            synthetic_u8_frame("gray8", 3, 1, |_plane, _x, _y| 1).expect("frame 1"),
182        ];
183
184        let Err(error) = synthetic_clip_from_frames(
185            "synthetic",
186            frames,
187            Rational {
188                numerator: 24,
189                denominator: 1,
190            },
191        ) else {
192            panic!("mismatched synthetic clip should fail");
193        };
194
195        assert_eq!(error.code().as_str(), "test.inconsistent_synthetic_clip");
196    }
197}