Skip to main content

dioxuscut_composition/
scene_emitter.rs

1//! Composable browser-free scene emitters.
2
3use 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/// Timeline state passed through a native scene-emitter tree.
11#[derive(Debug, Clone, Copy, PartialEq)]
12pub struct SceneFrameContext {
13    /// Frame local to the current sequence or freeze boundary.
14    pub frame: u32,
15    /// Unmodified composition frame.
16    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
42/// Shared primitive contract for media, shapes, captions, transitions, and apps.
43///
44/// Emitters append nodes to the supplied scene and can be nested through
45/// [`SceneSequence`], [`SceneFreeze`], [`SceneGroup`], and [`SceneStack`].
46pub 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/// Heterogeneous emitter collection rendered in insertion order.
82#[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
124/// Native equivalent of Dioxuscut's `<Sequence>` timeline boundary.
125pub 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
175/// Native equivalent of `<Freeze>`.
176pub 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
199/// Applies the Scene graph's transform and opacity primitive to emitted children.
200pub 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
246/// Creates an offscreen compositing boundary around emitted children.
247pub 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/// Font-aware multiline text that resolves into ordinary baseline Text nodes.
330#[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
430/// Adapts an emitter tree to the existing composition registry contract.
431pub 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}