Skip to main content

rustmotion_components/
waveform.rs

1use schemars::JsonSchema;
2use serde::{Deserialize, Serialize};
3use skia_safe::{Canvas, Color, Paint, PaintStyle, PathBuilder};
4
5use rustmotion_core::css::CssStyle;
6use rustmotion_core::engine::animator::AnimatedProperties;
7use rustmotion_core::engine::layout_pass::BoxLayout;
8use rustmotion_core::engine::renderer::audio_analysis::audio_analysis_cache;
9use rustmotion_core::engine::renderer::parse_hex_color;
10use rustmotion_core::schema::TimelineStep;
11use rustmotion_core::traits::{PaintCtx, Painter, TimingConfig};
12
13fn default_color() -> String {
14    "#38bdf8".to_string()
15}
16fn default_window() -> f32 {
17    2.0
18}
19
20#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize, JsonSchema)]
21#[serde(rename_all = "snake_case")]
22pub enum DrawStyle {
23    #[default]
24    Line,
25    Filled,
26}
27
28#[derive(Debug, Serialize, Deserialize, JsonSchema)]
29pub struct Waveform {
30    /// Source audio track (src path). If None, uses the first track in the cache.
31    #[serde(default)]
32    pub track: Option<String>,
33    /// Waveform color as hex string.
34    #[serde(default = "default_color")]
35    pub color: String,
36    /// Visual style: line or filled.
37    #[serde(default)]
38    pub draw_style: DrawStyle,
39    /// Time window in seconds, centered on ctx.scenario_time.
40    #[serde(default = "default_window")]
41    pub window: f32,
42    #[serde(flatten)]
43    pub timing: TimingConfig,
44    #[serde(default)]
45    pub style: CssStyle,
46    #[serde(default)]
47    pub timeline: Vec<TimelineStep>,
48    #[serde(default)]
49    pub stagger: Option<f32>,
50}
51
52rustmotion_core::impl_traits!(Waveform {
53    Animatable => animation,
54    Timed => timing,
55    Styled => style,
56});
57
58impl Painter for Waveform {
59    fn paint_content(
60        &self,
61        canvas: &Canvas,
62        layout: &BoxLayout,
63        _props: &AnimatedProperties,
64        ctx: &PaintCtx,
65    ) {
66        let w = layout.width;
67        let h = layout.height;
68        let (r, g, b, a) = parse_hex_color(&self.color);
69        let color = Color::from_argb(a, r, g, b);
70
71        let cache = audio_analysis_cache();
72        let analysis = if let Some(ref src) = self.track {
73            cache.get(src).map(|r| r.clone())
74        } else {
75            cache.iter().next().map(|r| r.value().clone())
76        };
77
78        let Some(analysis) = analysis else {
79            // Graceful degradation: flat line at mid-height
80            let mut paint = Paint::default();
81            paint.set_color(color);
82            paint.set_style(PaintStyle::Stroke);
83            paint.set_stroke_width(1.0);
84            paint.set_anti_alias(true);
85            canvas.draw_line(
86                skia_safe::Point::new(0.0, h / 2.0),
87                skia_safe::Point::new(w, h / 2.0),
88                &paint,
89            );
90            return;
91        };
92
93        let half_window = self.window as f64 / 2.0;
94        let t_start = (ctx.scenario_time - half_window).max(0.0);
95        let t_end = ctx.scenario_time + half_window;
96
97        // Sample N points along the window
98        let n_points = w as usize;
99        let mut points: Vec<(f32, f32)> = Vec::with_capacity(n_points);
100        for i in 0..n_points {
101            let t = t_start + (i as f64 / n_points.max(1) as f64) * (t_end - t_start);
102            let amp = analysis.amplitude_at(t);
103            let x = i as f32;
104            let y = h / 2.0 - amp * (h / 2.0 - 2.0);
105            points.push((x, y));
106        }
107
108        let mut paint = Paint::default();
109        paint.set_color(color);
110        paint.set_anti_alias(true);
111
112        match self.draw_style {
113            DrawStyle::Line => {
114                paint.set_style(PaintStyle::Stroke);
115                paint.set_stroke_width(1.5);
116                if points.len() < 2 {
117                    return;
118                }
119                let mut path = PathBuilder::new();
120                path.move_to((points[0].0, points[0].1));
121                for &(x, y) in &points[1..] {
122                    path.line_to((x, y));
123                }
124                canvas.draw_path(&path.detach(), &paint);
125            }
126            DrawStyle::Filled => {
127                // Filled area
128                let (r2, g2, b2, _) = parse_hex_color(&self.color);
129                let fill_color = Color::from_argb(100, r2, g2, b2);
130                paint.set_style(PaintStyle::Fill);
131                paint.set_color(fill_color);
132                if points.is_empty() {
133                    return;
134                }
135                let mut fill_path = PathBuilder::new();
136                fill_path.move_to((0.0, h / 2.0));
137                for &(x, y) in &points {
138                    fill_path.line_to((x, y));
139                }
140                fill_path.line_to((w, h / 2.0));
141                fill_path.close();
142                canvas.draw_path(&fill_path.detach(), &paint);
143
144                // Outline
145                paint.set_style(PaintStyle::Stroke);
146                paint.set_stroke_width(1.5);
147                paint.set_color(color);
148                if points.len() >= 2 {
149                    let mut outline = PathBuilder::new();
150                    outline.move_to((points[0].0, points[0].1));
151                    for &(x, y) in &points[1..] {
152                        outline.line_to((x, y));
153                    }
154                    canvas.draw_path(&outline.detach(), &paint);
155                }
156            }
157        }
158    }
159}