1mod 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#[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 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#[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
50pub trait PreparedComposition: Send + Sync {
56 fn render(&self, frame: u32) -> Result<Scene, CompositionError>;
57}
58
59pub 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
70pub 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#[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#[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
170pub 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
179pub 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}