Skip to main content

dioxuscut_cli/
composition.rs

1//! Native composition contract and built-in composition registry.
2
3use dioxuscut_rasterizer::{Color, GradientStop, Scene, SceneNode};
4use serde_json::Value;
5use std::collections::BTreeMap;
6use thiserror::Error;
7
8/// Immutable render parameters supplied to every native composition frame.
9#[derive(Debug, Clone, Copy, PartialEq)]
10pub struct NativeCompositionContext {
11    pub width: u32,
12    pub height: u32,
13    pub fps: f64,
14    pub duration_in_frames: u32,
15}
16
17impl NativeCompositionContext {
18    /// Normalized timeline progress in the inclusive range `0.0..=1.0`.
19    pub fn progress(self, frame: u32) -> f32 {
20        let last_frame = self.duration_in_frames.saturating_sub(1).max(1);
21        (frame.min(last_frame) as f32 / last_frame as f32).clamp(0.0, 1.0)
22    }
23}
24
25/// Errors produced while preparing or rendering a composition.
26#[derive(Debug, Error, PartialEq, Eq)]
27pub enum CompositionError {
28    #[error("Failed to prepare composition: {0}")]
29    Prepare(String),
30    #[error("Failed to render frame {frame}: {reason}")]
31    Render { frame: u32, reason: String },
32}
33
34impl CompositionError {
35    pub fn render(frame: u32, reason: impl Into<String>) -> Self {
36        Self::Render {
37            frame,
38            reason: reason.into(),
39        }
40    }
41}
42
43/// A composition instance prepared once for a complete render job.
44///
45/// Implementations may cache parsed input, compiled scripts, and other
46/// immutable state here. `render` can be called concurrently for different
47/// frames.
48pub trait PreparedComposition: Send + Sync {
49    fn render(&self, frame: u32) -> Result<Scene, CompositionError>;
50}
51
52/// General composition contract used by the registry.
53pub trait Composition: Send + Sync {
54    fn id(&self) -> &str;
55
56    fn prepare(
57        &self,
58        props: &Value,
59        context: NativeCompositionContext,
60    ) -> Result<Box<dyn PreparedComposition + '_>, CompositionError>;
61}
62
63/// A browser-free Rust composition that produces one rasterizer scene per frame.
64///
65/// Applications can implement this trait, register implementations in a
66/// [`CompositionRegistry`], and call `execute_render_command_with_registry`.
67pub trait NativeComposition: Send + Sync {
68    fn id(&self) -> &str;
69
70    fn render(
71        &self,
72        frame: u32,
73        props: &Value,
74        context: NativeCompositionContext,
75    ) -> Result<Scene, CompositionError>;
76}
77
78struct PreparedNativeComposition<'a, C> {
79    composition: &'a C,
80    props: Value,
81    context: NativeCompositionContext,
82}
83
84impl<C> PreparedComposition for PreparedNativeComposition<'_, C>
85where
86    C: NativeComposition,
87{
88    fn render(&self, frame: u32) -> Result<Scene, CompositionError> {
89        self.composition.render(frame, &self.props, self.context)
90    }
91}
92
93impl<C> Composition for C
94where
95    C: NativeComposition,
96{
97    fn id(&self) -> &str {
98        NativeComposition::id(self)
99    }
100
101    fn prepare(
102        &self,
103        props: &Value,
104        context: NativeCompositionContext,
105    ) -> Result<Box<dyn PreparedComposition + '_>, CompositionError> {
106        Ok(Box::new(PreparedNativeComposition {
107            composition: self,
108            props: props.clone(),
109            context,
110        }))
111    }
112}
113
114/// Errors produced while building or querying a composition registry.
115#[derive(Debug, Error, PartialEq, Eq)]
116pub enum CompositionRegistryError {
117    #[error("Composition '{0}' is already registered")]
118    Duplicate(String),
119    #[error("Unknown composition '{requested}'. Available compositions: {available}")]
120    Unknown {
121        requested: String,
122        available: String,
123    },
124}
125
126/// Deterministic registry used by the CLI to resolve `--composition` IDs.
127#[derive(Default)]
128pub struct CompositionRegistry {
129    compositions: BTreeMap<String, Box<dyn Composition>>,
130}
131
132impl CompositionRegistry {
133    pub fn new() -> Self {
134        Self::default()
135    }
136
137    pub fn register<C>(&mut self, composition: C) -> Result<(), CompositionRegistryError>
138    where
139        C: Composition + 'static,
140    {
141        let id = composition.id().to_string();
142        if self.compositions.contains_key(&id) {
143            return Err(CompositionRegistryError::Duplicate(id));
144        }
145        self.compositions.insert(id, Box::new(composition));
146        Ok(())
147    }
148
149    pub fn get(&self, id: &str) -> Result<&dyn Composition, CompositionRegistryError> {
150        self.compositions.get(id).map(Box::as_ref).ok_or_else(|| {
151            CompositionRegistryError::Unknown {
152                requested: id.to_string(),
153                available: self.ids().join(", "),
154            }
155        })
156    }
157
158    pub fn ids(&self) -> Vec<&str> {
159        self.compositions.keys().map(String::as_str).collect()
160    }
161}
162
163/// Registry shipped by the standalone `dioxuscut` binary.
164pub fn built_in_registry() -> CompositionRegistry {
165    let mut registry = CompositionRegistry::new();
166    registry
167        .register(HelloWorldComposition)
168        .expect("built-in composition IDs must be unique");
169    registry
170}
171
172/// Built-in native composition used by the quickstart and acceptance tests.
173pub struct HelloWorldComposition;
174
175impl NativeComposition for HelloWorldComposition {
176    fn id(&self) -> &str {
177        "HelloWorld"
178    }
179
180    fn render(
181        &self,
182        frame: u32,
183        props: &Value,
184        context: NativeCompositionContext,
185    ) -> Result<Scene, CompositionError> {
186        let width = context.width as f32;
187        let height = context.height as f32;
188        let t = context.progress(frame);
189
190        let bg_start = color_prop(props, "background_start", Color::rgb(15, 23, 42));
191        let bg_end = color_prop(props, "background_end", Color::rgb(30, 27, 75));
192        let accent = color_prop(props, "accent_color", Color::rgb(108, 99, 255));
193        let title = string_prop(props, "title", "Hello Dioxuscut");
194        let subtitle = string_prop(props, "subtitle", "Declarative programmatic video in Rust");
195
196        let mut scene = Scene::new();
197        scene.push(SceneNode::LinearGradient {
198            x: 0.0,
199            y: 0.0,
200            w: width,
201            h: height,
202            angle_deg: 135.0 + t * 90.0,
203            stops: vec![
204                GradientStop {
205                    position: 0.0,
206                    color: bg_start,
207                },
208                GradientStop {
209                    position: 1.0,
210                    color: bg_end,
211                },
212            ],
213        });
214
215        let center_x = width * 0.5;
216        let center_y = height * 0.5;
217        let shortest_side = width.min(height);
218        let r1 = shortest_side * 0.2 + (t * std::f32::consts::TAU).sin() * 20.0;
219        scene.push(SceneNode::Circle {
220            cx: center_x,
221            cy: center_y,
222            r: r1,
223            fill: accent.with_opacity(0.12),
224            stroke: Some(accent),
225            stroke_width: 2.0,
226        });
227
228        let r2 = shortest_side * 0.3 + (t * std::f32::consts::PI).cos() * 30.0;
229        scene.push(SceneNode::Circle {
230            cx: center_x,
231            cy: center_y,
232            r: r2,
233            fill: Color::TRANSPARENT,
234            stroke: Some(Color::rgba(0, 242, 254, 180)),
235            stroke_width: 1.5,
236        });
237
238        let rect_size = 80.0 + (t * std::f32::consts::TAU).sin() * 15.0;
239        scene.push(SceneNode::Rect {
240            x: width * 0.15,
241            y: height * 0.2,
242            w: rect_size,
243            h: rect_size,
244            fill: Color::rgba(0, 242, 254, 40),
245            stroke: Some(Color::rgb(0, 242, 254)),
246            stroke_width: 2.0,
247            corner_radius: 12.0,
248        });
249        scene.push(SceneNode::Rect {
250            x: width * 0.78,
251            y: height * 0.65,
252            w: rect_size * 1.2,
253            h: rect_size * 1.2,
254            fill: Color::rgba(255, 230, 0, 30),
255            stroke: Some(Color::rgb(255, 230, 0)),
256            stroke_width: 2.0,
257            corner_radius: 16.0,
258        });
259
260        scene.push(SceneNode::Rect {
261            x: 0.0,
262            y: height - 6.0,
263            w: width * t,
264            h: 6.0,
265            fill: Color::rgb(0, 242, 254),
266            stroke: None,
267            stroke_width: 0.0,
268            corner_radius: 0.0,
269        });
270
271        let font_size = (width * 0.045).max(28.0);
272        let text_x = width * 0.12;
273        let text_y = height * 0.45;
274        scene.push(SceneNode::Text {
275            x: text_x,
276            y: text_y,
277            content: title,
278            font_size,
279            color: Color::WHITE,
280            font_weight: 700,
281        });
282        scene.push(SceneNode::Text {
283            x: text_x,
284            y: text_y + font_size * 0.8,
285            content: subtitle,
286            font_size: font_size * 0.45,
287            color: Color::rgb(0, 242, 254),
288            font_weight: 400,
289        });
290        Ok(scene)
291    }
292}
293
294fn color_prop(props: &Value, key: &str, fallback: Color) -> Color {
295    props
296        .get(key)
297        .and_then(Value::as_str)
298        .and_then(Color::from_hex)
299        .unwrap_or(fallback)
300}
301
302fn string_prop(props: &Value, key: &str, fallback: &str) -> String {
303    props
304        .get(key)
305        .and_then(Value::as_str)
306        .filter(|value| !value.trim().is_empty())
307        .unwrap_or(fallback)
308        .to_string()
309}
310
311#[cfg(test)]
312mod tests {
313    use super::*;
314
315    #[test]
316    fn registry_rejects_duplicate_ids_and_reports_known_ids() {
317        let mut registry = CompositionRegistry::new();
318        registry.register(HelloWorldComposition).unwrap();
319        assert_eq!(
320            registry.register(HelloWorldComposition),
321            Err(CompositionRegistryError::Duplicate("HelloWorld".into()))
322        );
323
324        let error = match registry.get("Missing") {
325            Ok(_) => panic!("missing composition unexpectedly resolved"),
326            Err(error) => error,
327        };
328        assert_eq!(
329            error,
330            CompositionRegistryError::Unknown {
331                requested: "Missing".into(),
332                available: "HelloWorld".into(),
333            }
334        );
335    }
336
337    #[test]
338    fn hello_world_uses_props_and_reaches_full_progress() {
339        let context = NativeCompositionContext {
340            width: 100,
341            height: 100,
342            fps: 30.0,
343            duration_in_frames: 3,
344        };
345        let props = serde_json::json!({"title": "Custom title"});
346        let scene = HelloWorldComposition.render(2, &props, context).unwrap();
347
348        assert!(scene.nodes.iter().any(|node| matches!(
349            node,
350            SceneNode::Text { content, .. } if content == "Custom title"
351        )));
352        assert!(scene.nodes.iter().any(|node| matches!(
353            node,
354            SceneNode::Rect { w, h, .. } if *w == 100.0 && *h == 6.0
355        )));
356    }
357}