Skip to main content

dioxuscut_composition/
lib.rs

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