1use rustmotion_core::error::Result;
2use schemars::JsonSchema;
3use serde::{Deserialize, Serialize};
4use skia_safe::{Canvas, Paint, PaintStyle, Point};
5
6use rustmotion_core::css::{CssStyle, FrameClock};
7use rustmotion_core::engine::animator::{ease, AnimatedProperties};
8use rustmotion_core::engine::layout_pass::BoxLayout;
9use rustmotion_core::engine::renderer::{
10 build_shape_path, color4f_from_hex, draw_shape_path, draw_text_with_fallback, emoji_typeface,
11 interpolate_path_data, measure_text_with_fallback, paint_from_hex, subpixel_font,
12 trim_path_between, typeface_with_fallback, wrap_text_with_tracking,
13};
14use rustmotion_core::expr::{Computed, Expr, Scope};
15use rustmotion_core::schema::{
16 EasingType, Fill, FontWeight, GradientType, LineCap, LineJoin, ShapeText, ShapeType, Stroke,
17 TextAlign, TimelineStep,
18};
19use rustmotion_core::traits::{PaintCtx, Painter, TimingConfig};
20
21#[derive(Debug, Serialize, Deserialize, JsonSchema)]
22pub struct Shape {
23 pub shape: ShapeType,
24 #[serde(default)]
25 pub text: Option<ShapeText>,
26 #[serde(flatten)]
27 pub timing: TimingConfig,
28 #[serde(default)]
29 pub style: CssStyle,
30 #[serde(default)]
31 pub timeline: Vec<TimelineStep>,
32 #[serde(default)]
33 pub stagger: Option<f32>,
34 #[serde(default)]
35 pub fill: Option<Fill>,
36 #[serde(default)]
37 pub stroke: Option<Stroke>,
38 #[serde(default)]
48 pub draw_start: Option<Computed<f32>>,
49 #[serde(default)]
56 pub path_morph: Option<PathMorph>,
57}
58
59#[derive(Debug, Serialize, Deserialize, JsonSchema)]
62pub struct PathMorph {
63 pub keyframes: Vec<PathMorphKeyframe>,
65 #[serde(default)]
67 pub easing: EasingType,
68 #[serde(default)]
71 pub repeat: bool,
72 #[serde(default)]
75 pub yoyo: bool,
76}
77
78#[derive(Debug, Serialize, Deserialize, JsonSchema)]
80pub struct PathMorphKeyframe {
81 pub time: f64,
84 pub value: String,
86}
87
88rustmotion_core::impl_traits!(Shape {
89 Animatable => animation,
90 Timed => timing,
91 Styled => style,
92});
93
94fn frame_clock(ctx: &PaintCtx) -> FrameClock {
95 FrameClock {
96 t: ctx.time,
97 t_abs: ctx.scenario_time,
98 duration: ctx.scene_duration,
99 width: ctx.video_width as f64,
100 height: ctx.video_height as f64,
101 fps: ctx.fps as f64,
102 }
103}
104
105fn skia_line_cap(cap: LineCap) -> skia_safe::PaintCap {
106 match cap {
107 LineCap::Butt => skia_safe::PaintCap::Butt,
108 LineCap::Round => skia_safe::PaintCap::Round,
109 LineCap::Square => skia_safe::PaintCap::Square,
110 }
111}
112
113fn skia_line_join(join: LineJoin) -> skia_safe::PaintJoin {
114 match join {
115 LineJoin::Miter => skia_safe::PaintJoin::Miter,
116 LineJoin::Round => skia_safe::PaintJoin::Round,
117 LineJoin::Bevel => skia_safe::PaintJoin::Bevel,
118 }
119}
120
121fn resolve_dash_offset(offset: &Option<Computed<f32>>, scope: &dyn Scope) -> f32 {
122 match offset {
123 None => 0.0,
124 Some(Computed::Literal(v)) => *v,
125 Some(Computed::Expr(src)) => Expr::parse(src)
126 .and_then(|e| e.eval(scope))
127 .map(|v| v as f32)
128 .unwrap_or(0.0),
129 }
130}
131
132fn resolve_draw_start(value: &Option<Computed<f32>>, scope: &dyn Scope) -> f32 {
133 let raw = match value {
134 None => 0.0,
135 Some(Computed::Literal(v)) => *v,
136 Some(Computed::Expr(src)) => Expr::parse(src)
137 .and_then(|e| e.eval(scope))
138 .map(|v| v as f32)
139 .unwrap_or(0.0),
140 };
141 raw.clamp(0.0, 1.0)
142}
143
144fn resolve_path_morph(morph: &PathMorph, time: f64) -> Option<skia_safe::Path> {
145 let keyframes = &morph.keyframes;
146 let first = keyframes.first()?;
147 if keyframes.len() == 1 {
148 return skia_safe::Path::from_svg(&first.value);
149 }
150 let last = keyframes.last()?;
151 let first_time = first.time;
152 let last_time = last.time;
153 let span = (last_time - first_time).max(1e-9);
154
155 let sample_time = if morph.repeat && time > first_time {
156 let elapsed = time - first_time;
157 let cycle = elapsed.rem_euclid(span);
158 let forward = elapsed.div_euclid(span) as i64 % 2 == 0;
159 if morph.yoyo && !forward {
160 last_time - cycle
161 } else {
162 first_time + cycle
163 }
164 } else {
165 time.clamp(first_time, last_time)
166 };
167
168 let mut lower = first;
169 let mut upper = last;
170 for pair in keyframes.windows(2) {
171 if sample_time >= pair[0].time && sample_time <= pair[1].time {
172 lower = &pair[0];
173 upper = &pair[1];
174 break;
175 }
176 }
177
178 if lower.value == upper.value {
179 return skia_safe::Path::from_svg(&lower.value);
180 }
181 let segment_span = (upper.time - lower.time).max(1e-9);
182 let local_t = ((sample_time - lower.time) / segment_span).clamp(0.0, 1.0);
183 let eased = ease(local_t, &morph.easing) as f32;
184 interpolate_path_data(&lower.value, &upper.value, eased)
185}
186
187fn resolve_template(s: &str, scope: &dyn Scope) -> std::result::Result<Option<String>, ()> {
188 if !s.contains("${") {
189 return Ok(None);
190 }
191 let mut out = String::with_capacity(s.len());
192 let mut rest = s;
193 loop {
194 match rest.find("${") {
195 None => {
196 out.push_str(rest);
197 break;
198 }
199 Some(start) => {
200 out.push_str(&rest[..start]);
201 let after = &rest[start + 2..];
202 let end = after.find('}').ok_or(())?;
203 let marker = &after[..end];
204 let (src, format_spec) = match marker.rsplit_once('|') {
205 Some((src, spec)) => (src, Some(spec)),
206 None => (marker, None),
207 };
208 let value = Expr::parse(src)
209 .map_err(|_| ())?
210 .eval(scope)
211 .map_err(|_| ())?;
212 match format_spec {
213 None => out.push_str(&format!("{value:.4}")),
214 Some("02x") => {
215 let byte = value.round().clamp(0.0, 255.0) as i64;
216 out.push_str(&format!("{byte:02x}"));
217 }
218 Some(_) => return Err(()),
219 }
220 rest = &after[end + 1..];
221 }
222 }
223 }
224 Ok(Some(out))
225}
226
227impl Painter for Shape {
228 fn paint_content(
229 &self,
230 canvas: &Canvas,
231 layout: &BoxLayout,
232 props: &AnimatedProperties,
233 ctx: &PaintCtx,
234 ) {
235 let w = layout.width;
236 let h = layout.height;
237 let corner_radius = self.style.border_radius_px();
238
239 let clock = frame_clock(ctx);
240 let mut resolved_path = None;
241 if let ShapeType::Path { data } = &self.shape {
242 match resolve_template(data, &clock) {
243 Ok(Some(resolved)) => resolved_path = Some(ShapeType::Path { data: resolved }),
244 Ok(None) => {}
245 Err(()) => return,
246 }
247 }
248 let shape = resolved_path.as_ref().unwrap_or(&self.shape);
249 let morphed_path = self
250 .path_morph
251 .as_ref()
252 .and_then(|morph| resolve_path_morph(morph, clock.t));
253
254 if let Some(fill) = &self.fill {
255 let paint: Option<Paint> = match fill {
256 Fill::Solid(color) => match resolve_template(color, &clock) {
257 Ok(resolved) => Some(paint_from_hex(resolved.as_deref().unwrap_or(color))),
258 Err(()) => None,
259 },
260 Fill::Gradient(gradient) => Some({
261 let colors: Vec<skia_safe::Color4f> = gradient
262 .colors
263 .iter()
264 .map(|c| color4f_from_hex(c))
265 .collect();
266 let stops: Option<Vec<f32>> = gradient
267 .stops
268 .as_ref()
269 .filter(|s| s.len() == colors.len())
270 .cloned();
271 let mut paint = Paint::default();
272 paint.set_anti_alias(true);
273
274 let shader = match gradient.gradient_type {
275 GradientType::Linear => {
276 let angle = gradient.angle.unwrap_or(0.0);
277 let rad = angle.to_radians();
278 let cx = w / 2.0;
279 let cy = h / 2.0;
280 let dx = (w / 2.0) * rad.cos();
281 let dy = (h / 2.0) * rad.sin();
282 let start = Point::new(cx - dx, cy - dy);
283 let end = Point::new(cx + dx, cy + dy);
284 let gradient_colors = skia_safe::gradient::Colors::new(
285 &colors,
286 stops.as_deref(),
287 skia_safe::TileMode::Clamp,
288 Some(skia_safe::ColorSpace::new_srgb()),
289 );
290 let g = skia_safe::gradient::Gradient::new(
291 gradient_colors,
292 skia_safe::gradient::Interpolation::default(),
293 );
294 skia_safe::gradient::shaders::linear_gradient((start, end), &g, None)
295 }
296 GradientType::Radial => {
297 let center = Point::new(w / 2.0, h / 2.0);
298 let radius = w.max(h) / 2.0;
299 let gradient_colors = skia_safe::gradient::Colors::new(
300 &colors,
301 stops.as_deref(),
302 skia_safe::TileMode::Clamp,
303 Some(skia_safe::ColorSpace::new_srgb()),
304 );
305 let g = skia_safe::gradient::Gradient::new(
306 gradient_colors,
307 skia_safe::gradient::Interpolation::default(),
308 );
309 skia_safe::gradient::shaders::radial_gradient(
310 (center, radius),
311 &g,
312 None,
313 )
314 }
315 };
316 if let Some(shader) = shader {
317 paint.set_shader(shader);
318 paint.set_dither(true);
319 }
320 paint
321 }),
322 };
323 if let Some(mut paint) = paint {
324 paint.set_style(PaintStyle::Fill);
325 match &morphed_path {
326 Some(path) => {
327 canvas.draw_path(path, &paint);
328 }
329 None => draw_shape_path(canvas, shape, 0.0, 0.0, w, h, corner_radius, &paint),
330 };
331 }
332 }
333
334 if let Some(stroke) = &self.stroke {
335 let mut paint = paint_from_hex(&stroke.color);
336 paint.set_style(PaintStyle::Stroke);
337 let stroke_w = if props.stroke_width >= 0.0 {
338 props.stroke_width
339 } else {
340 stroke.width
341 };
342 paint.set_stroke_width(stroke_w);
343 paint.set_stroke_cap(skia_line_cap(stroke.line_cap));
344 paint.set_stroke_join(skia_line_join(stroke.line_join));
345
346 let expr_draw_start = resolve_draw_start(&self.draw_start, &clock);
347 let draw_start = if props.draw_start >= 0.0 {
348 props.draw_start.clamp(0.0, 1.0)
349 } else {
350 expr_draw_start
351 };
352 let draw_offset = props.draw_offset;
353 let mut trimmed_path = None;
354
355 if let Some(intervals) = stroke.dashed.as_ref().filter(|v| v.len() >= 2) {
356 let phase = resolve_dash_offset(&stroke.dash_offset, &clock);
357 if let Some(dash) = skia_safe::PathEffect::dash(intervals, phase) {
358 paint.set_path_effect(dash);
359 }
360 } else {
361 let drawing = props.draw_progress >= 0.0 && props.draw_progress < 1.0;
362 let trimming = draw_start > 0.0 || draw_offset.abs() > 0.0005;
363 if drawing || trimming {
364 let end = if props.draw_progress >= 0.0 {
365 props.draw_progress.clamp(0.0, 1.0)
366 } else {
367 1.0
368 };
369 let trim_start = (draw_start + draw_offset).clamp(0.0, 1.0);
370 let trim_end = (end + draw_offset).clamp(0.0, 1.0);
371 if trim_end <= trim_start {
372 trimmed_path = Some(skia_safe::PathBuilder::new().detach());
373 } else {
374 let base = morphed_path
375 .clone()
376 .or_else(|| build_shape_path(shape, 0.0, 0.0, w, h, corner_radius));
377 if let Some(path) = base {
378 trimmed_path = Some(trim_path_between(&path, trim_start, trim_end));
379 }
380 }
381 }
382 }
383
384 match trimmed_path.as_ref().or(morphed_path.as_ref()) {
385 Some(path) => {
386 canvas.draw_path(path, &paint);
387 }
388 None => draw_shape_path(canvas, shape, 0.0, 0.0, w, h, corner_radius, &paint),
389 };
390 }
391
392 if let Some(text) = &self.text {
393 let _ = render_shape_text(canvas, text, 0.0, 0.0, w, h);
394 }
395 }
396}
397
398fn render_shape_text(
399 canvas: &Canvas,
400 text: &ShapeText,
401 shape_x: f32,
402 shape_y: f32,
403 shape_w: f32,
404 shape_h: f32,
405) -> Result<()> {
406 use rustmotion_core::schema::VerticalAlign;
407
408 let pad = text.padding.unwrap_or(0.0);
409 let area_x = shape_x + pad;
410 let area_y = shape_y + pad;
411 let area_w = shape_w - 2.0 * pad;
412 let area_h = shape_h - 2.0 * pad;
413
414 let font_style = match text.font_weight {
415 FontWeight::Bold => skia_safe::FontStyle::bold(),
416 FontWeight::Normal => skia_safe::FontStyle::normal(),
417 FontWeight::Weight(w) => skia_safe::FontStyle::new(
418 skia_safe::font_style::Weight::from(w as i32),
419 skia_safe::font_style::Width::NORMAL,
420 skia_safe::font_style::Slant::Upright,
421 ),
422 };
423
424 let typeface = typeface_with_fallback(&text.font_family, font_style)?;
425
426 let font = subpixel_font(typeface, text.font_size);
427 let emoji_font = emoji_typeface().map(|tf| subpixel_font(tf, text.font_size));
428 let (_strike_width, metrics) = font.metrics();
429 let ascent = -metrics.ascent;
430 let line_height = match text.line_height {
431 Some(v) if v <= 10.0 => text.font_size * v,
432 Some(v) => v,
433 None => text.font_size * 1.3,
434 };
435 let letter_spacing = text.letter_spacing.unwrap_or(0.0);
436
437 let lines = wrap_text_with_tracking(
438 &text.content,
439 &font,
440 &emoji_font,
441 Some(area_w),
442 letter_spacing,
443 );
444 let descent = metrics.descent;
445 let total_h = if lines.len() > 1 {
446 (lines.len() - 1) as f32 * line_height + ascent + descent
447 } else {
448 ascent + descent
449 };
450
451 let y_start = match text.vertical_align {
452 VerticalAlign::Top => area_y + ascent,
453 VerticalAlign::Middle => area_y + (area_h - total_h) / 2.0 + ascent,
454 VerticalAlign::Bottom => area_y + area_h - total_h + ascent,
455 };
456
457 let mut paint = paint_from_hex(&text.color);
458 paint.set_alpha_f(1.0);
459
460 for (i, line) in lines.iter().enumerate() {
461 if line.is_empty() {
462 continue;
463 }
464
465 let line_width = measure_text_with_fallback(line, &font, &emoji_font, letter_spacing);
466
467 let x = match text.align {
468 TextAlign::Left => area_x,
469 TextAlign::Center => area_x + (area_w - line_width) / 2.0,
470 TextAlign::Right => area_x + area_w - line_width,
471 };
472 let y = y_start + i as f32 * line_height;
473 draw_text_with_fallback(
474 canvas,
475 line,
476 &font,
477 &emoji_font,
478 letter_spacing,
479 x,
480 y,
481 &paint,
482 );
483 }
484
485 Ok(())
486}
487
488#[cfg(test)]
489mod tests {
490 use super::*;
491 use rustmotion_core::traits::TimingConfig;
492 use skia_safe::{surfaces, AlphaType, ColorType, ImageInfo};
493
494 const W: i32 = 200;
495 const H: i32 = 20;
496
497 fn ctx_at(time: f64) -> PaintCtx {
498 PaintCtx {
499 time,
500 scenario_time: time,
501 scene_duration: 1.0,
502 frame_index: 0,
503 fps: 30,
504 video_width: W as u32,
505 video_height: H as u32,
506 stagger_offset: 0.0,
507 }
508 }
509
510 fn layout() -> BoxLayout {
511 BoxLayout {
512 width: W as f32,
513 height: H as f32,
514 ..Default::default()
515 }
516 }
517
518 fn straight_line(color: &str) -> Shape {
519 Shape {
520 shape: ShapeType::Path {
521 data: format!("M0 {} L{} {}", H / 2, W, H / 2),
522 },
523 text: None,
524 timing: TimingConfig::default(),
525 style: CssStyle::default(),
526 timeline: vec![],
527 stagger: None,
528 fill: None,
529 stroke: Some(Stroke {
530 color: color.to_string(),
531 width: 4.0,
532 dashed: None,
533 dash_offset: None,
534 line_cap: LineCap::Butt,
535 line_join: LineJoin::Miter,
536 }),
537 draw_start: None,
538 path_morph: None,
539 }
540 }
541
542 fn render_at(shape: &Shape, props: &AnimatedProperties, time: f64) -> Vec<u8> {
543 let info = ImageInfo::new((W, H), ColorType::RGBA8888, AlphaType::Unpremul, None);
544 let mut surface = surfaces::raster(&info, None, None).expect("raster surface");
545 surface.canvas().clear(skia_safe::Color::BLACK);
546 shape.paint_content(surface.canvas(), &layout(), props, &ctx_at(time));
547 let row_bytes = W as usize * 4;
548 let mut pixels = vec![0u8; row_bytes * H as usize];
549 surface.read_pixels(&info, &mut pixels, row_bytes, (0, 0));
550 pixels
551 }
552
553 fn render(shape: &Shape, props: &AnimatedProperties) -> Vec<u8> {
554 render_at(shape, props, 0.0)
555 }
556
557 fn pixel(buf: &[u8], x: i32, y: i32) -> [u8; 4] {
558 let base = ((y * W + x) * 4) as usize;
559 [buf[base], buf[base + 1], buf[base + 2], buf[base + 3]]
560 }
561
562 fn is_red(px: [u8; 4]) -> bool {
563 px[0] > 200 && px[1] < 40 && px[2] < 40
564 }
565
566 #[test]
567 fn draw_start_leaves_no_ink_before_it() {
568 let mut shape = straight_line("#FF0000");
569 shape.draw_start = Some(Computed::Literal(0.5));
570 let props = AnimatedProperties {
571 draw_progress: 1.0,
572 ..AnimatedProperties::default()
573 };
574 let out = render(&shape, &props);
575
576 for x in [5, 40, 90] {
577 assert!(
578 !is_red(pixel(&out, x, H / 2)),
579 "x={x} is before draw_start=0.5 on a 200px line and must have no ink"
580 );
581 }
582 for x in [110, 150, 195] {
583 assert!(
584 is_red(pixel(&out, x, H / 2)),
585 "x={x} is after draw_start=0.5 and before draw_progress=1.0, so it must be lit"
586 );
587 }
588 }
589
590 #[test]
591 fn draw_start_absent_matches_the_pre_existing_draw_progress_behaviour() {
592 let with_zero = {
593 let mut shape = straight_line("#FF0000");
594 shape.draw_start = Some(Computed::Literal(0.0));
595 let props = AnimatedProperties {
596 draw_progress: 0.5,
597 ..AnimatedProperties::default()
598 };
599 render(&shape, &props)
600 };
601 let without_field = {
602 let shape = straight_line("#FF0000");
603 let props = AnimatedProperties {
604 draw_progress: 0.5,
605 ..AnimatedProperties::default()
606 };
607 render(&shape, &props)
608 };
609 assert_eq!(
610 with_zero, without_field,
611 "an explicit draw_start of 0.0 must render identically to draw_start absent"
612 );
613 }
614
615 #[test]
616 fn draw_start_without_an_active_draw_progress_erases_from_the_tail() {
617 let mut shape = straight_line("#FF0000");
618 shape.draw_start = Some(Computed::Literal(0.5));
619 let props = AnimatedProperties::default();
620 let out = render(&shape, &props);
621
622 assert!(
623 !is_red(pixel(&out, 20, H / 2)),
624 "with draw_progress never animated, draw_start still trims the head of the stroke"
625 );
626 assert!(
627 is_red(pixel(&out, 180, H / 2)),
628 "the tail (up to the implicit end of 1.0) must remain lit"
629 );
630 }
631
632 #[test]
633 fn a_keyframe_driven_draw_start_is_honored_even_with_no_static_draw_start_field() {
634 let shape = straight_line("#FF0000");
635 let props = AnimatedProperties {
636 draw_start: 0.5,
637 draw_progress: 1.0,
638 ..AnimatedProperties::default()
639 };
640 let out = render(&shape, &props);
641
642 for x in [5, 40, 90] {
643 assert!(
644 !is_red(pixel(&out, x, H / 2)),
645 "x={x} is before a keyframe-driven draw_start=0.5 and must have no ink — \
646 keyframes on draw_start must not be a no-op on shape"
647 );
648 }
649 for x in [110, 150, 195] {
650 assert!(
651 is_red(pixel(&out, x, H / 2)),
652 "x={x} is after the keyframe-driven draw_start=0.5 and must be lit"
653 );
654 }
655 }
656
657 #[test]
658 fn draw_offset_marches_the_drawn_window_along_the_path() {
659 let shape = straight_line("#FF0000");
660 let no_offset = render(
661 &shape,
662 &AnimatedProperties {
663 draw_progress: 0.3,
664 ..AnimatedProperties::default()
665 },
666 );
667 let offset = render(
668 &shape,
669 &AnimatedProperties {
670 draw_progress: 0.3,
671 draw_offset: 0.5,
672 ..AnimatedProperties::default()
673 },
674 );
675
676 assert!(
677 is_red(pixel(&no_offset, 20, H / 2)),
678 "with no offset the window starts at the path's own beginning"
679 );
680 assert!(
681 !is_red(pixel(&offset, 20, H / 2)),
682 "draw_offset=0.5 must march the window forward, leaving the path's start empty"
683 );
684 assert!(
685 is_red(pixel(&offset, 140, H / 2)),
686 "and paint further along the path instead"
687 );
688 }
689
690 #[test]
691 fn path_morph_interpolates_between_same_structure_paths() {
692 let mut shape = straight_line("#FF0000");
693 shape.stroke = None;
694 shape.fill = Some(Fill::Solid("#FF0000".to_string()));
695 shape.path_morph = Some(PathMorph {
696 keyframes: vec![
697 PathMorphKeyframe {
698 time: 0.0,
699 value: "M0 0 L10 0 L10 10 L0 10 Z".to_string(),
700 },
701 PathMorphKeyframe {
702 time: 1.0,
703 value: format!("M0 0 L{W} 0 L{W} {H} L0 {H} Z"),
704 },
705 ],
706 easing: EasingType::Linear,
707 repeat: false,
708 yoyo: false,
709 });
710
711 let out = render_at(&shape, &AnimatedProperties::default(), 0.5);
712
713 assert!(
714 is_red(pixel(&out, 5, H / 2)),
715 "the near corner must already be inside the halfway-grown rectangle"
716 );
717 assert!(
718 !is_red(pixel(&out, W - 5, H / 2)),
719 "the far corner must not be covered yet at t=0.5 of the morph"
720 );
721 }
722
723 #[test]
724 fn path_morph_with_a_structure_mismatch_does_not_panic_and_holds_a_shape() {
725 let mut shape = straight_line("#FF0000");
726 shape.stroke = None;
727 shape.fill = Some(Fill::Solid("#FF0000".to_string()));
728 shape.path_morph = Some(PathMorph {
729 keyframes: vec![
730 PathMorphKeyframe {
731 time: 0.0,
732 value: "M0 0 L10 0 L10 10 Z".to_string(),
733 },
734 PathMorphKeyframe {
735 time: 1.0,
736 value: "M0 0 L10 0 L10 10 L5 15 L0 10 Z".to_string(),
737 },
738 ],
739 easing: EasingType::Linear,
740 repeat: false,
741 yoyo: false,
742 });
743 let _ = render_at(&shape, &AnimatedProperties::default(), 0.5);
744 }
745}