1use serde::{Deserialize, Serialize};
7
8#[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 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 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 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#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
118pub struct GradientStop {
119 pub position: f32, pub color: Color,
121}
122
123#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
125#[serde(rename_all = "kebab-case")]
126pub enum ImageFit {
127 #[default]
129 Cover,
130 Contain,
132 Fill,
134 None,
136 ScaleDown,
138}
139
140#[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#[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#[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#[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#[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#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
207pub struct AudioTrack {
208 pub src: String,
210 pub start_from: f64,
212 pub timeline_start: f64,
214 pub duration: Option<f64>,
216 pub volume: f64,
218 pub playback_rate: f64,
220 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#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
240pub enum SceneNode {
241 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 {
255 cx: f32,
256 cy: f32,
257 r: f32,
258 fill: Color,
259 stroke: Option<Color>,
260 stroke_width: f32,
261 },
262
263 Path {
265 d: String,
266 fill: Option<Color>,
267 stroke: Option<Color>,
268 stroke_width: f32,
269 opacity: f32,
270 },
271
272 Text {
274 x: f32,
275 y: f32,
276 content: String,
277 font_size: f32,
278 color: Color,
279 font_weight: u16, #[serde(default)]
283 font_sources: Vec<String>,
284 },
285
286 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 Video {
299 src: String,
300 time: f64,
301 #[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 Audio { track: AudioTrack },
314
315 LinearGradient {
317 x: f32,
318 y: f32,
319 w: f32,
320 h: f32,
321 angle_deg: f32,
322 stops: Vec<GradientStop>,
323 },
324
325 RadialGradient {
327 cx: f32,
328 cy: f32,
329 r: f32,
330 stops: Vec<GradientStop>,
331 },
332
333 Group {
335 transform: Transform2D,
336 opacity: f32,
337 children: Vec<SceneNode>,
338 },
339
340 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#[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#[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 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}