1use crate::{CompositionError, NativeComposition, NativeCompositionContext};
4use dioxuscut_rasterizer::{
5 layout_text_box, BlendMode, ClipRegion, Color, MaskMode, Scene, SceneFilter, SceneNode,
6 SceneShadow, TextBox, TextHorizontalAlign, TextOverflow, TextVerticalAlign, Transform2D,
7};
8use serde_json::Value;
9
10#[derive(Debug, Clone, Copy, PartialEq)]
12pub struct SceneFrameContext {
13 pub frame: u32,
15 pub global_frame: u32,
17 pub composition: NativeCompositionContext,
18}
19
20impl SceneFrameContext {
21 pub fn new(frame: u32, composition: NativeCompositionContext) -> Self {
22 Self {
23 frame,
24 global_frame: frame,
25 composition,
26 }
27 }
28
29 pub fn time_secs(self) -> f64 {
30 self.frame as f64 / self.composition.fps
31 }
32
33 pub fn global_time_secs(self) -> f64 {
34 self.global_frame as f64 / self.composition.fps
35 }
36
37 fn with_local_frame(self, frame: u32) -> Self {
38 Self { frame, ..self }
39 }
40}
41
42pub trait SceneEmitter: Send + Sync {
47 fn emit(
48 &self,
49 context: SceneFrameContext,
50 props: &Value,
51 scene: &mut Scene,
52 ) -> Result<(), CompositionError>;
53}
54
55impl<F> SceneEmitter for F
56where
57 F: Fn(SceneFrameContext, &Value, &mut Scene) -> Result<(), CompositionError> + Send + Sync,
58{
59 fn emit(
60 &self,
61 context: SceneFrameContext,
62 props: &Value,
63 scene: &mut Scene,
64 ) -> Result<(), CompositionError> {
65 self(context, props, scene)
66 }
67}
68
69impl SceneEmitter for SceneNode {
70 fn emit(
71 &self,
72 _context: SceneFrameContext,
73 _props: &Value,
74 scene: &mut Scene,
75 ) -> Result<(), CompositionError> {
76 scene.push(self.clone());
77 Ok(())
78 }
79}
80
81#[derive(Default)]
83pub struct SceneStack {
84 children: Vec<Box<dyn SceneEmitter>>,
85}
86
87impl SceneStack {
88 pub fn new() -> Self {
89 Self::default()
90 }
91
92 pub fn push(&mut self, child: impl SceneEmitter + 'static) {
93 self.children.push(Box::new(child));
94 }
95
96 pub fn with(mut self, child: impl SceneEmitter + 'static) -> Self {
97 self.push(child);
98 self
99 }
100
101 pub fn len(&self) -> usize {
102 self.children.len()
103 }
104
105 pub fn is_empty(&self) -> bool {
106 self.children.is_empty()
107 }
108}
109
110impl SceneEmitter for SceneStack {
111 fn emit(
112 &self,
113 context: SceneFrameContext,
114 props: &Value,
115 scene: &mut Scene,
116 ) -> Result<(), CompositionError> {
117 for child in &self.children {
118 child.emit(context, props, scene)?;
119 }
120 Ok(())
121 }
122}
123
124pub struct SceneSequence<E> {
126 pub from: u32,
127 pub duration_in_frames: Option<u32>,
128 pub hidden: bool,
129 pub child: E,
130}
131
132impl<E> SceneSequence<E> {
133 pub fn new(from: u32, child: E) -> Self {
134 Self {
135 from,
136 duration_in_frames: None,
137 hidden: false,
138 child,
139 }
140 }
141
142 pub fn with_duration(mut self, duration_in_frames: u32) -> Self {
143 self.duration_in_frames = Some(duration_in_frames);
144 self
145 }
146
147 pub fn hidden(mut self, hidden: bool) -> Self {
148 self.hidden = hidden;
149 self
150 }
151}
152
153impl<E: SceneEmitter> SceneEmitter for SceneSequence<E> {
154 fn emit(
155 &self,
156 context: SceneFrameContext,
157 props: &Value,
158 scene: &mut Scene,
159 ) -> Result<(), CompositionError> {
160 let end = self
161 .duration_in_frames
162 .map(|duration| self.from.saturating_add(duration))
163 .unwrap_or(u32::MAX);
164 if self.hidden || context.frame < self.from || context.frame >= end {
165 return Ok(());
166 }
167 self.child.emit(
168 context.with_local_frame(context.frame - self.from),
169 props,
170 scene,
171 )
172 }
173}
174
175pub struct SceneFreeze<E> {
177 pub frame: u32,
178 pub child: E,
179}
180
181impl<E> SceneFreeze<E> {
182 pub fn new(frame: u32, child: E) -> Self {
183 Self { frame, child }
184 }
185}
186
187impl<E: SceneEmitter> SceneEmitter for SceneFreeze<E> {
188 fn emit(
189 &self,
190 context: SceneFrameContext,
191 props: &Value,
192 scene: &mut Scene,
193 ) -> Result<(), CompositionError> {
194 self.child
195 .emit(context.with_local_frame(self.frame), props, scene)
196 }
197}
198
199pub struct SceneGroup<E> {
201 pub transform: Transform2D,
202 pub opacity: f32,
203 pub child: E,
204}
205
206impl<E> SceneGroup<E> {
207 pub fn new(child: E) -> Self {
208 Self {
209 transform: Transform2D::default(),
210 opacity: 1.0,
211 child,
212 }
213 }
214
215 pub fn with_transform(mut self, transform: Transform2D) -> Self {
216 self.transform = transform;
217 self
218 }
219
220 pub fn with_opacity(mut self, opacity: f32) -> Self {
221 self.opacity = opacity.clamp(0.0, 1.0);
222 self
223 }
224}
225
226impl<E: SceneEmitter> SceneEmitter for SceneGroup<E> {
227 fn emit(
228 &self,
229 context: SceneFrameContext,
230 props: &Value,
231 scene: &mut Scene,
232 ) -> Result<(), CompositionError> {
233 let mut child_scene = Scene::new();
234 self.child.emit(context, props, &mut child_scene)?;
235 if !child_scene.nodes.is_empty() {
236 scene.push(SceneNode::Group {
237 transform: self.transform,
238 opacity: self.opacity,
239 children: child_scene.nodes,
240 });
241 }
242 Ok(())
243 }
244}
245
246pub struct SceneLayer<E> {
248 pub opacity: f32,
249 pub blend_mode: BlendMode,
250 pub clip: Option<ClipRegion>,
251 pub mask: Option<Vec<SceneNode>>,
252 pub mask_mode: MaskMode,
253 pub filters: Vec<SceneFilter>,
254 pub shadow: Option<SceneShadow>,
255 pub child: E,
256}
257
258impl<E> SceneLayer<E> {
259 pub fn new(child: E) -> Self {
260 Self {
261 opacity: 1.0,
262 blend_mode: BlendMode::Normal,
263 clip: None,
264 mask: None,
265 mask_mode: MaskMode::Alpha,
266 filters: Vec::new(),
267 shadow: None,
268 child,
269 }
270 }
271
272 pub fn with_opacity(mut self, opacity: f32) -> Self {
273 self.opacity = opacity.clamp(0.0, 1.0);
274 self
275 }
276
277 pub fn with_blend_mode(mut self, blend_mode: BlendMode) -> Self {
278 self.blend_mode = blend_mode;
279 self
280 }
281
282 pub fn with_clip(mut self, clip: ClipRegion) -> Self {
283 self.clip = Some(clip);
284 self
285 }
286
287 pub fn with_mask(mut self, nodes: impl IntoIterator<Item = SceneNode>, mode: MaskMode) -> Self {
288 self.mask = Some(nodes.into_iter().collect());
289 self.mask_mode = mode;
290 self
291 }
292
293 pub fn with_filter(mut self, filter: SceneFilter) -> Self {
294 self.filters.push(filter);
295 self
296 }
297
298 pub fn with_shadow(mut self, shadow: SceneShadow) -> Self {
299 self.shadow = Some(shadow);
300 self
301 }
302}
303
304impl<E: SceneEmitter> SceneEmitter for SceneLayer<E> {
305 fn emit(
306 &self,
307 context: SceneFrameContext,
308 props: &Value,
309 scene: &mut Scene,
310 ) -> Result<(), CompositionError> {
311 let mut child_scene = Scene::new();
312 self.child.emit(context, props, &mut child_scene)?;
313 if !child_scene.nodes.is_empty() {
314 scene.push(SceneNode::Layer {
315 opacity: self.opacity,
316 blend_mode: self.blend_mode,
317 clip: self.clip.clone(),
318 mask: self.mask.clone(),
319 mask_mode: self.mask_mode,
320 filters: self.filters.clone(),
321 shadow: self.shadow.clone(),
322 children: child_scene.nodes,
323 });
324 }
325 Ok(())
326 }
327}
328
329#[derive(Debug, Clone, PartialEq)]
331pub struct SceneTextBlock {
332 pub layout: TextBox,
333 pub color: Color,
334 pub font_weight: u16,
335}
336
337impl SceneTextBlock {
338 pub fn new(
339 text: impl Into<String>,
340 x: f32,
341 y: f32,
342 width: f32,
343 height: f32,
344 font_size: f32,
345 ) -> Self {
346 Self {
347 layout: TextBox::new(text, x, y, width, height, font_size),
348 color: Color::WHITE,
349 font_weight: 400,
350 }
351 }
352
353 pub fn with_min_font_size(mut self, min_font_size: f32) -> Self {
354 self.layout.min_font_size = min_font_size;
355 self
356 }
357
358 pub fn with_line_height(mut self, line_height: f32) -> Self {
359 self.layout.line_height = line_height;
360 self
361 }
362
363 pub fn with_max_lines(mut self, max_lines: usize) -> Self {
364 self.layout.max_lines = Some(max_lines);
365 self
366 }
367
368 pub fn with_alignment(
369 mut self,
370 horizontal: TextHorizontalAlign,
371 vertical: TextVerticalAlign,
372 ) -> Self {
373 self.layout.horizontal_align = horizontal;
374 self.layout.vertical_align = vertical;
375 self
376 }
377
378 pub fn with_overflow(mut self, overflow: TextOverflow) -> Self {
379 self.layout.overflow = overflow;
380 self
381 }
382
383 pub fn with_font_sources(mut self, sources: impl IntoIterator<Item = String>) -> Self {
384 self.layout.font_sources = sources.into_iter().collect();
385 self
386 }
387
388 pub fn with_color(mut self, color: Color) -> Self {
389 self.color = color;
390 self
391 }
392
393 pub fn with_font_weight(mut self, font_weight: u16) -> Self {
394 self.font_weight = font_weight;
395 self
396 }
397}
398
399impl SceneEmitter for SceneTextBlock {
400 fn emit(
401 &self,
402 context: SceneFrameContext,
403 _props: &Value,
404 scene: &mut Scene,
405 ) -> Result<(), CompositionError> {
406 let layout = layout_text_box(&self.layout).map_err(|error| {
407 CompositionError::render(
408 context.global_frame,
409 format!("failed to layout text block: {error}"),
410 )
411 })?;
412 for line in layout.lines {
413 if line.text.is_empty() {
414 continue;
415 }
416 scene.push(SceneNode::Text {
417 x: line.x,
418 y: line.y,
419 content: line.text,
420 font_size: layout.font_size,
421 color: self.color,
422 font_weight: self.font_weight,
423 font_sources: self.layout.font_sources.clone(),
424 });
425 }
426 Ok(())
427 }
428}
429
430pub struct SceneEmitterComposition<E> {
432 id: String,
433 root: E,
434}
435
436impl<E> SceneEmitterComposition<E> {
437 pub fn new(id: impl Into<String>, root: E) -> Self {
438 Self {
439 id: id.into(),
440 root,
441 }
442 }
443
444 pub fn root(&self) -> &E {
445 &self.root
446 }
447}
448
449impl<E: SceneEmitter> NativeComposition for SceneEmitterComposition<E> {
450 fn id(&self) -> &str {
451 &self.id
452 }
453
454 fn render(
455 &self,
456 frame: u32,
457 props: &Value,
458 context: NativeCompositionContext,
459 ) -> Result<Scene, CompositionError> {
460 let mut scene = Scene::new();
461 self.root
462 .emit(SceneFrameContext::new(frame, context), props, &mut scene)?;
463 Ok(scene)
464 }
465}
466
467#[cfg(test)]
468mod tests {
469 use super::*;
470 use dioxuscut_rasterizer::{Color, ImageFit};
471
472 fn context() -> NativeCompositionContext {
473 NativeCompositionContext {
474 width: 320,
475 height: 180,
476 fps: 30.0,
477 duration_in_frames: 90,
478 }
479 }
480
481 fn frame_text() -> impl SceneEmitter {
482 |context: SceneFrameContext, _props: &Value, scene: &mut Scene| {
483 scene.push(SceneNode::Text {
484 x: 0.0,
485 y: 20.0,
486 content: context.frame.to_string(),
487 font_size: 20.0,
488 color: Color::WHITE,
489 font_weight: 400,
490 font_sources: Vec::new(),
491 });
492 Ok(())
493 }
494 }
495
496 #[test]
497 fn sequence_uses_local_frames_and_duration() {
498 let composition = SceneEmitterComposition::new(
499 "sequence",
500 SceneSequence::new(10, frame_text()).with_duration(5),
501 );
502 let active = composition.render(12, &Value::Null, context()).unwrap();
503 let inactive = composition.render(15, &Value::Null, context()).unwrap();
504
505 assert!(matches!(
506 &active.nodes[0],
507 SceneNode::Text { content, .. } if content == "2"
508 ));
509 assert!(inactive.nodes.is_empty());
510 }
511
512 #[test]
513 fn freeze_replaces_only_the_local_frame() {
514 let composition = SceneEmitterComposition::new("freeze", SceneFreeze::new(7, frame_text()));
515 let scene = composition.render(42, &Value::Null, context()).unwrap();
516 assert!(matches!(
517 &scene.nodes[0],
518 SceneNode::Text { content, .. } if content == "7"
519 ));
520 }
521
522 #[test]
523 fn stack_and_group_preserve_primitive_order() {
524 let image = SceneNode::Image {
525 src: "card.png".into(),
526 x: 0.0,
527 y: 0.0,
528 w: 100.0,
529 h: 50.0,
530 fit: ImageFit::Contain,
531 opacity: 1.0,
532 };
533 let stack = SceneStack::new().with(image).with(frame_text());
534 let composition =
535 SceneEmitterComposition::new("group", SceneGroup::new(stack).with_opacity(0.5));
536 let scene = composition.render(3, &Value::Null, context()).unwrap();
537
538 assert!(matches!(
539 &scene.nodes[0],
540 SceneNode::Group { opacity, children, .. }
541 if (*opacity - 0.5).abs() < f32::EPSILON && children.len() == 2
542 ));
543 }
544
545 #[test]
546 fn layer_collects_compositing_options_and_children() {
547 let layer = SceneLayer::new(frame_text())
548 .with_opacity(0.75)
549 .with_blend_mode(BlendMode::Multiply)
550 .with_clip(ClipRegion::Rect {
551 x: 0.0,
552 y: 0.0,
553 w: 100.0,
554 h: 100.0,
555 corner_radius: 8.0,
556 })
557 .with_filter(SceneFilter::Grayscale { amount: 1.0 })
558 .with_shadow(SceneShadow {
559 offset_x: 4.0,
560 offset_y: 6.0,
561 blur_sigma: 3.0,
562 color: Color::rgba(0, 0, 0, 128),
563 });
564 let composition = SceneEmitterComposition::new("layer", layer);
565 let scene = composition.render(3, &Value::Null, context()).unwrap();
566
567 assert!(matches!(
568 &scene.nodes[0],
569 SceneNode::Layer {
570 opacity,
571 blend_mode: BlendMode::Multiply,
572 clip: Some(ClipRegion::Rect { .. }),
573 filters,
574 shadow: Some(_),
575 children,
576 ..
577 } if (*opacity - 0.75).abs() < f32::EPSILON
578 && filters.len() == 1
579 && children.len() == 1
580 ));
581 }
582
583 #[test]
584 fn text_block_resolves_wrapping_fitting_and_alignment() {
585 let font_cache = dioxuscut_rasterizer::FontCache::load();
586 let Some(font) = font_cache.font_path() else {
587 return;
588 };
589 let block =
590 SceneTextBlock::new("one two three four five six", 10.0, 20.0, 120.0, 52.0, 32.0)
591 .with_min_font_size(14.0)
592 .with_max_lines(2)
593 .with_alignment(TextHorizontalAlign::Center, TextVerticalAlign::Center)
594 .with_overflow(TextOverflow::Ellipsis)
595 .with_font_sources([font.to_string()]);
596 let composition = SceneEmitterComposition::new("text-block", block);
597 let scene = composition.render(0, &Value::Null, context()).unwrap();
598
599 assert!(!scene.nodes.is_empty());
600 assert!(scene.nodes.len() <= 2);
601 assert!(scene.nodes.iter().all(|node| matches!(
602 node,
603 SceneNode::Text { x, font_size, .. } if *x >= 10.0 && *font_size <= 32.0
604 )));
605 }
606}