1use dioxuscut_rasterizer::{Color, GradientStop, Scene, SceneNode};
4use serde_json::Value;
5use std::collections::BTreeMap;
6use thiserror::Error;
7
8#[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 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#[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
43pub trait PreparedComposition: Send + Sync {
49 fn render(&self, frame: u32) -> Result<Scene, CompositionError>;
50}
51
52pub 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
63pub 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#[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#[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
163pub 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
172pub 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}