Skip to main content

dioxuscut_rasterizer/
scene.rs

1//! Scene graph intermediate representation for native rendering.
2//!
3//! Compositions write into a [`Scene`] instead of DOM nodes.
4//! The rasterizer backend reads the scene and produces pixel output.
5
6use serde::{Deserialize, Serialize};
7
8/// RGBA color: `[red, green, blue, alpha]` each in `0..=255`.
9#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
10pub struct Color {
11    pub r: u8,
12    pub g: u8,
13    pub b: u8,
14    pub a: u8,
15}
16
17impl Color {
18    pub const fn rgba(r: u8, g: u8, b: u8, a: u8) -> Self {
19        Self { r, g, b, a }
20    }
21
22    pub const fn rgb(r: u8, g: u8, b: u8) -> Self {
23        Self::rgba(r, g, b, 255)
24    }
25
26    pub const TRANSPARENT: Self = Self::rgba(0, 0, 0, 0);
27    pub const BLACK: Self = Self::rgb(0, 0, 0);
28    pub const WHITE: Self = Self::rgb(255, 255, 255);
29
30    /// Parse a CSS hex color: `"#rrggbb"` or `"#rrggbbaa"`.
31    pub fn from_hex(hex: &str) -> Option<Self> {
32        let s = hex.trim_start_matches('#');
33        match s.len() {
34            6 => {
35                let r = u8::from_str_radix(&s[0..2], 16).ok()?;
36                let g = u8::from_str_radix(&s[2..4], 16).ok()?;
37                let b = u8::from_str_radix(&s[4..6], 16).ok()?;
38                Some(Self::rgb(r, g, b))
39            }
40            8 => {
41                let r = u8::from_str_radix(&s[0..2], 16).ok()?;
42                let g = u8::from_str_radix(&s[2..4], 16).ok()?;
43                let b = u8::from_str_radix(&s[4..6], 16).ok()?;
44                let a = u8::from_str_radix(&s[6..8], 16).ok()?;
45                Some(Self::rgba(r, g, b, a))
46            }
47            _ => None,
48        }
49    }
50
51    /// Parse common CSS color forms used by Dioxuscut primitives.
52    pub fn from_css(value: &str) -> Option<Self> {
53        let value = value.trim();
54        if let Some(color) = Self::from_hex(value) {
55            return Some(color);
56        }
57        match value.to_ascii_lowercase().as_str() {
58            "black" => return Some(Self::BLACK),
59            "white" => return Some(Self::WHITE),
60            "transparent" => return Some(Self::TRANSPARENT),
61            _ => {}
62        }
63
64        let (contents, has_alpha) = if let Some(contents) = value
65            .strip_prefix("rgba(")
66            .and_then(|value| value.strip_suffix(')'))
67        {
68            (contents, true)
69        } else {
70            let contents = value
71                .strip_prefix("rgb(")
72                .and_then(|value| value.strip_suffix(')'))?;
73            (contents, false)
74        };
75        let components = contents.split(',').map(str::trim).collect::<Vec<_>>();
76        if components.len() != if has_alpha { 4 } else { 3 } {
77            return None;
78        }
79        let channel = |value: &str| -> Option<u8> {
80            if let Some(percent) = value.strip_suffix('%') {
81                let percent = percent.parse::<f32>().ok()?;
82                Some((percent.clamp(0.0, 100.0) * 2.55).round() as u8)
83            } else {
84                Some(value.parse::<f32>().ok()?.clamp(0.0, 255.0).round() as u8)
85            }
86        };
87        let alpha = if has_alpha {
88            let value = components[3];
89            if let Some(percent) = value.strip_suffix('%') {
90                (percent.parse::<f32>().ok()?.clamp(0.0, 100.0) * 2.55).round() as u8
91            } else {
92                (value.parse::<f32>().ok()?.clamp(0.0, 1.0) * 255.0).round() as u8
93            }
94        } else {
95            255
96        };
97        Some(Self::rgba(
98            channel(components[0])?,
99            channel(components[1])?,
100            channel(components[2])?,
101            alpha,
102        ))
103    }
104
105    /// Apply opacity (0.0 – 1.0) to this colour.
106    pub fn with_opacity(mut self, opacity: f32) -> Self {
107        self.a = (self.a as f32 * opacity.clamp(0.0, 1.0)) as u8;
108        self
109    }
110
111    pub fn to_tiny_skia_color(self) -> tiny_skia::Color {
112        tiny_skia::Color::from_rgba8(self.r, self.g, self.b, self.a)
113    }
114}
115
116/// A gradient colour stop.
117#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
118pub struct GradientStop {
119    pub position: f32, // 0.0 – 1.0
120    pub color: Color,
121}
122
123/// How a raster image is fitted into its destination rectangle.
124#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
125#[serde(rename_all = "kebab-case")]
126pub enum ImageFit {
127    /// Preserve aspect ratio and fill the destination, cropping overflow.
128    #[default]
129    Cover,
130    /// Preserve aspect ratio and show the complete image with transparent letterboxing.
131    Contain,
132    /// Stretch the image to exactly match the destination.
133    Fill,
134    /// Keep the image at its natural pixel size, centered and clipped.
135    None,
136    /// Use the natural size unless the image must be reduced to fit.
137    ScaleDown,
138}
139
140/// Layer blend mode shared by CPU rendering and SVG preview.
141#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
142#[serde(rename_all = "kebab-case")]
143pub enum BlendMode {
144    #[default]
145    Normal,
146    Multiply,
147    Screen,
148    Overlay,
149    Darken,
150    Lighten,
151    ColorDodge,
152    ColorBurn,
153    HardLight,
154    SoftLight,
155    Difference,
156    Exclusion,
157}
158
159/// Geometric clip applied to a composited layer.
160#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
161pub enum ClipRegion {
162    Rect {
163        x: f32,
164        y: f32,
165        w: f32,
166        h: f32,
167        corner_radius: f32,
168    },
169    Path {
170        d: String,
171    },
172}
173
174/// How rendered mask nodes are converted to coverage.
175#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
176#[serde(rename_all = "kebab-case")]
177pub enum MaskMode {
178    #[default]
179    Alpha,
180    Luminance,
181}
182
183/// Pixel filter applied to an offscreen layer in declaration order.
184#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
185pub enum SceneFilter {
186    Blur { sigma: f32 },
187    Brightness { amount: f32 },
188    Grayscale { amount: f32 },
189    Opacity { amount: f32 },
190}
191
192/// Drop shadow generated from the filtered and masked layer alpha.
193#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
194pub struct SceneShadow {
195    pub offset_x: f32,
196    pub offset_y: f32,
197    pub blur_sigma: f32,
198    pub color: Color,
199}
200
201const fn default_opacity() -> f32 {
202    1.0
203}
204
205/// Audio source mixed into the encoded output.
206#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
207pub struct AudioTrack {
208    /// Local audio or video file containing an audio stream.
209    pub src: String,
210    /// Source offset in seconds.
211    pub start_from: f64,
212    /// Composition timeline offset in seconds.
213    pub timeline_start: f64,
214    /// Optional audible duration on the composition timeline.
215    pub duration: Option<f64>,
216    /// Linear gain in `0.0..=1.0`.
217    pub volume: f64,
218    /// Playback speed supported by FFmpeg `atempo` (`0.5..=2.0`).
219    pub playback_rate: f64,
220    /// Repeat the source when it is shorter than the requested duration.
221    pub looped: bool,
222}
223
224impl AudioTrack {
225    pub fn new(src: impl Into<String>) -> Self {
226        Self {
227            src: src.into(),
228            start_from: 0.0,
229            timeline_start: 0.0,
230            duration: None,
231            volume: 1.0,
232            playback_rate: 1.0,
233            looped: false,
234        }
235    }
236}
237
238/// A node in the scene graph.
239#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
240pub enum SceneNode {
241    /// Solid-coloured rectangle with optional corner radius.
242    Rect {
243        x: f32,
244        y: f32,
245        w: f32,
246        h: f32,
247        fill: Color,
248        stroke: Option<Color>,
249        stroke_width: f32,
250        corner_radius: f32,
251    },
252
253    /// Circle.
254    Circle {
255        cx: f32,
256        cy: f32,
257        r: f32,
258        fill: Color,
259        stroke: Option<Color>,
260        stroke_width: f32,
261    },
262
263    /// Arbitrary SVG path string (M, L, C, Q, Z commands).
264    Path {
265        d: String,
266        fill: Option<Color>,
267        stroke: Option<Color>,
268        stroke_width: f32,
269        opacity: f32,
270    },
271
272    /// Single-line text.
273    Text {
274        x: f32,
275        y: f32,
276        content: String,
277        font_size: f32,
278        color: Color,
279        font_weight: u16, // 400 = normal, 700 = bold
280        /// Ordered local TTF/OTF sources. Missing glyphs fall through to the
281        /// next source and finally the renderer's system fallback.
282        #[serde(default)]
283        font_sources: Vec<String>,
284    },
285
286    /// Local raster image asset. `src` may be a filesystem path or `file://` URI.
287    Image {
288        src: String,
289        x: f32,
290        y: f32,
291        w: f32,
292        h: f32,
293        fit: ImageFit,
294        opacity: f32,
295    },
296
297    /// A decoded frame from a local video file at `time` seconds.
298    Video {
299        src: String,
300        time: f64,
301        /// Repeat the video timeline after the source duration.
302        #[serde(default)]
303        looped: bool,
304        x: f32,
305        y: f32,
306        w: f32,
307        h: f32,
308        fit: ImageFit,
309        opacity: f32,
310    },
311
312    /// Non-visual audio track collected by the output encoder.
313    Audio { track: AudioTrack },
314
315    /// Linear gradient background covering a rectangle.
316    LinearGradient {
317        x: f32,
318        y: f32,
319        w: f32,
320        h: f32,
321        angle_deg: f32,
322        stops: Vec<GradientStop>,
323    },
324
325    /// Radial gradient background.
326    RadialGradient {
327        cx: f32,
328        cy: f32,
329        r: f32,
330        stops: Vec<GradientStop>,
331    },
332
333    /// Group with a 2D transform applied to all children.
334    Group {
335        transform: Transform2D,
336        opacity: f32,
337        children: Vec<SceneNode>,
338    },
339
340    /// Offscreen compositing boundary with filters, clipping, masking, shadow,
341    /// and a destination blend mode.
342    Layer {
343        #[serde(default = "default_opacity")]
344        opacity: f32,
345        #[serde(default)]
346        blend_mode: BlendMode,
347        #[serde(default)]
348        clip: Option<ClipRegion>,
349        #[serde(default)]
350        mask: Option<Vec<SceneNode>>,
351        #[serde(default)]
352        mask_mode: MaskMode,
353        #[serde(default)]
354        filters: Vec<SceneFilter>,
355        #[serde(default)]
356        shadow: Option<SceneShadow>,
357        children: Vec<SceneNode>,
358    },
359}
360
361/// Affine 2D transform (scale + rotation + translation).
362#[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize)]
363pub struct Transform2D {
364    pub tx: f32,
365    pub ty: f32,
366    pub scale_x: f32,
367    pub scale_y: f32,
368    pub rotate_deg: f32,
369}
370
371impl Default for Transform2D {
372    fn default() -> Self {
373        Self {
374            tx: 0.0,
375            ty: 0.0,
376            scale_x: 1.0,
377            scale_y: 1.0,
378            rotate_deg: 0.0,
379        }
380    }
381}
382
383impl Transform2D {
384    pub fn to_tiny_skia(&self) -> tiny_skia::Transform {
385        tiny_skia::Transform::from_translate(self.tx, self.ty)
386            .post_scale(self.scale_x, self.scale_y)
387            .post_rotate(self.rotate_deg)
388    }
389}
390
391/// The root container for a single video frame's scene.
392#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
393pub struct Scene {
394    pub nodes: Vec<SceneNode>,
395}
396
397impl Scene {
398    pub fn new() -> Self {
399        Self::default()
400    }
401
402    pub fn push(&mut self, node: SceneNode) {
403        self.nodes.push(node);
404    }
405
406    /// Collect audio declared in this scene, including nested groups and layers.
407    pub fn audio_tracks(&self) -> Vec<AudioTrack> {
408        fn collect(nodes: &[SceneNode], parent_volume: f64, output: &mut Vec<AudioTrack>) {
409            for node in nodes {
410                match node {
411                    SceneNode::Audio { track } => {
412                        let mut track = track.clone();
413                        track.volume *= parent_volume;
414                        output.push(track);
415                    }
416                    SceneNode::Group {
417                        opacity, children, ..
418                    } => collect(children, parent_volume * f64::from(*opacity), output),
419                    SceneNode::Layer {
420                        opacity, children, ..
421                    } => collect(children, parent_volume * f64::from(*opacity), output),
422                    _ => {}
423                }
424            }
425        }
426
427        let mut tracks = Vec::new();
428        collect(&self.nodes, 1.0, &mut tracks);
429        tracks
430    }
431}
432
433#[cfg(test)]
434mod tests {
435    use super::*;
436
437    #[test]
438    fn test_color_from_hex() {
439        let c = Color::from_hex("#ff6600").unwrap();
440        assert_eq!(c.r, 0xff);
441        assert_eq!(c.g, 0x66);
442        assert_eq!(c.b, 0x00);
443        assert_eq!(c.a, 0xff);
444    }
445
446    #[test]
447    fn css_rgb_and_rgba_colors_are_parsed() {
448        assert_eq!(
449            Color::from_css("rgb(255, 0, 128)"),
450            Some(Color::rgb(255, 0, 128))
451        );
452        assert_eq!(
453            Color::from_css("rgba(100%, 0%, 0%, 0.5)"),
454            Some(Color::rgba(255, 0, 0, 128))
455        );
456    }
457
458    #[test]
459    fn test_scene_push() {
460        let mut scene = Scene::new();
461        scene.push(SceneNode::Rect {
462            x: 0.0,
463            y: 0.0,
464            w: 100.0,
465            h: 100.0,
466            fill: Color::rgb(255, 0, 0),
467            stroke: None,
468            stroke_width: 0.0,
469            corner_radius: 0.0,
470        });
471        assert_eq!(scene.nodes.len(), 1);
472    }
473
474    #[test]
475    fn image_node_round_trips_through_json() {
476        let scene = Scene {
477            nodes: vec![SceneNode::Image {
478                src: "assets/card.png".into(),
479                x: 10.0,
480                y: 20.0,
481                w: 320.0,
482                h: 180.0,
483                fit: ImageFit::Contain,
484                opacity: 0.75,
485            }],
486        };
487
488        let json = serde_json::to_string(&scene).unwrap();
489        assert!(json.contains("contain"));
490        assert_eq!(serde_json::from_str::<Scene>(&json).unwrap(), scene);
491    }
492
493    #[test]
494    fn video_loop_defaults_to_false_for_existing_scene_json() {
495        let json = r#"{
496            "Video": {
497                "src": "clip.mp4", "time": 0.0,
498                "x": 0.0, "y": 0.0, "w": 10.0, "h": 10.0,
499                "fit": "cover", "opacity": 1.0
500            }
501        }"#;
502        let node = serde_json::from_str::<SceneNode>(json).unwrap();
503        assert!(matches!(node, SceneNode::Video { looped: false, .. }));
504    }
505
506    #[test]
507    fn text_font_sources_default_for_existing_scene_json() {
508        let json = r##"{
509            "Text": {
510                "x": 0.0, "y": 20.0, "content": "legacy",
511                "font_size": 18.0, "color": {"r":255,"g":255,"b":255,"a":255},
512                "font_weight": 400
513            }
514        }"##;
515        let node = serde_json::from_str::<SceneNode>(json).unwrap();
516
517        assert!(matches!(node, SceneNode::Text { font_sources, .. } if font_sources.is_empty()));
518    }
519
520    #[test]
521    fn nested_audio_tracks_inherit_group_opacity() {
522        let scene = Scene {
523            nodes: vec![SceneNode::Group {
524                transform: Transform2D::default(),
525                opacity: 0.5,
526                children: vec![SceneNode::Audio {
527                    track: AudioTrack::new("sound.wav"),
528                }],
529            }],
530        };
531
532        let tracks = scene.audio_tracks();
533        assert_eq!(tracks.len(), 1);
534        assert!((tracks[0].volume - 0.5).abs() < f64::EPSILON);
535    }
536
537    #[test]
538    fn nested_audio_tracks_inherit_layer_opacity() {
539        let mut track = AudioTrack::new("voice.wav");
540        track.volume = 0.8;
541        let scene = Scene {
542            nodes: vec![SceneNode::Layer {
543                opacity: 0.25,
544                blend_mode: BlendMode::Normal,
545                clip: None,
546                mask: None,
547                mask_mode: MaskMode::Alpha,
548                filters: Vec::new(),
549                shadow: None,
550                children: vec![SceneNode::Audio { track }],
551            }],
552        };
553
554        assert!((scene.audio_tracks()[0].volume - 0.2).abs() < f64::EPSILON);
555    }
556
557    #[test]
558    fn layer_deserialization_defaults_compositing_options() {
559        let node: SceneNode = serde_json::from_str(r#"{"Layer":{"children":[]}}"#).unwrap();
560        assert!(matches!(
561            node,
562            SceneNode::Layer {
563                opacity,
564                blend_mode: BlendMode::Normal,
565                mask_mode: MaskMode::Alpha,
566                ..
567            } if (opacity - 1.0).abs() < f32::EPSILON
568        ));
569    }
570}