summaryrefslogtreecommitdiff
path: root/rust
diff options
context:
space:
mode:
authorcashmere <cashmere@autistici.org>2026-07-28 20:14:51 +0200
committercashmere <cashmere@autistici.org>2026-07-28 20:14:51 +0200
commit32438ce25f69de99d2523b98237f62cfa90890d7 (patch)
tree3a712c347dec0889bdea3328b4c817c2d9392b27 /rust
parentf5a82152394fde4d8f740f664ab7737b82861618 (diff)
downloadopencut-32438ce25f69de99d2523b98237f62cfa90890d7.tar.gz
feat: port domain crates to Rust (session 1 complete)
All green: cargo test --workspace (~300 tests, 0 failures). - timeline (45): placement, resize/ripple, split, snap, retime, audio separation, scene helpers - animation (73): bezier solver, channel sampling, keyframe ops, split/clamp/clone, param definitions - commands (9): Command trait, BatchCommand, history, 19 concrete commands, freeze-frame port 1:1 - audio (22): audible collection, range mixing, mastering limiter, rms/peak buckets - text (19): cosmic-text layout/measure/raster, backgrounds, decorations - graphics (52): full CSS gradient parser, tiny-skia raster, 4 shape definitions - effect-defs (16): blur definition, registry, pass resolution - mask-defs (19): all 9 mask renderers via tiny-skia - renderer (6): node model + scene builder port - desktop: GPUI projects screen wired to ProjectStore - scene: factory (web defaults: v32, Main Track, 1920x1080, 30fps)
Diffstat (limited to 'rust')
-rw-r--r--rust/crates/animation/src/bezier.rs6
-rw-r--r--rust/crates/animation/src/color.rs5
-rw-r--r--rust/crates/animation/src/effect_param_channel.rs125
-rw-r--r--rust/crates/animation/src/graph_channels.rs184
-rw-r--r--rust/crates/animation/src/graphic_param_channel.rs102
-rw-r--r--rust/crates/animation/src/interpolation.rs11
-rw-r--r--rust/crates/animation/src/keyframe_query.rs308
-rw-r--r--rust/crates/animation/src/keyframes.rs363
-rw-r--r--rust/crates/animation/src/lib.rs74
-rw-r--r--rust/crates/animation/src/params.rs445
-rw-r--r--rust/crates/animation/src/path.rs23
-rw-r--r--rust/crates/animation/src/property_groups.rs110
-rw-r--r--rust/crates/animation/src/resolve.rs182
-rw-r--r--rust/crates/animation/src/values.rs89
-rw-r--r--rust/crates/audio/Cargo.toml11
-rw-r--r--rust/crates/audio/src/buckets.rs105
-rw-r--r--rust/crates/audio/src/clips.rs169
-rw-r--r--rust/crates/audio/src/lib.rs29
-rw-r--r--rust/crates/audio/src/mix.rs181
-rw-r--r--rust/crates/audio/tests/collection.rs263
-rw-r--r--rust/crates/audio/tests/fixtures/sample.mp4bin0 -> 81284 bytes
-rw-r--r--rust/crates/audio/tests/fixtures/tone.wavbin0 -> 88278 bytes
-rw-r--r--rust/crates/audio/tests/mix.rs183
-rw-r--r--rust/crates/commands/src/scene/create_scene.rs8
-rw-r--r--rust/crates/commands/src/timeline/element/delete_elements.rs5
-rw-r--r--rust/crates/commands/src/timeline/element/duplicate_elements.rs131
-rw-r--r--rust/crates/commands/src/timeline/element/insert_element.rs195
-rw-r--r--rust/crates/commands/src/timeline/element/mod.rs12
-rw-r--r--rust/crates/commands/src/timeline/element/move_elements.rs14
-rw-r--r--rust/crates/commands/src/timeline/element/split_elements.rs170
-rw-r--r--rust/crates/commands/src/timeline/element/update_elements.rs7
-rw-r--r--rust/crates/commands/src/timeline/mod.rs2
-rw-r--r--rust/crates/commands/src/timeline/track/add_track.rs2
-rw-r--r--rust/crates/commands/tests/common/mod.rs118
-rw-r--r--rust/crates/commands/tests/framework.rs162
-rw-r--r--rust/crates/commands/tests/freeze_frame.rs155
-rw-r--r--rust/crates/effect-defs/src/definitions/blur.rs147
-rw-r--r--rust/crates/effect-defs/src/definitions/mod.rs12
-rw-r--r--rust/crates/effect-defs/src/types.rs3
-rw-r--r--rust/crates/effect-defs/tests/effect_defs.rs270
-rw-r--r--rust/crates/graphics/Cargo.toml2
-rw-r--r--rust/crates/graphics/src/color.rs487
-rw-r--r--rust/crates/graphics/src/definitions.rs531
-rw-r--r--rust/crates/graphics/src/error.rs13
-rw-r--r--rust/crates/graphics/src/gradient/ast.rs105
-rw-r--r--rust/crates/graphics/src/gradient/mod.rs15
-rw-r--r--rust/crates/graphics/src/gradient/parser.rs614
-rw-r--r--rust/crates/graphics/src/gradient/raster.rs549
-rw-r--r--rust/crates/graphics/src/gradient/stops.rs151
-rw-r--r--rust/crates/graphics/src/lib.rs31
-rw-r--r--rust/crates/graphics/src/params.rs72
-rw-r--r--rust/crates/graphics/src/registry.rs107
-rw-r--r--rust/crates/graphics/src/stroke.rs71
-rw-r--r--rust/crates/graphics/src/types.rs77
-rw-r--r--rust/crates/graphics/tests/gradient_parser.rs375
-rw-r--r--rust/crates/graphics/tests/gradient_stops.rs176
-rw-r--r--rust/crates/graphics/tests/graphics_render.rs261
-rw-r--r--rust/crates/mask-defs/Cargo.toml2
-rw-r--r--rust/crates/mask-defs/src/freeform.rs87
-rw-r--r--rust/crates/mask-defs/src/geometry.rs109
-rw-r--r--rust/crates/mask-defs/src/lib.rs191
-rw-r--r--rust/crates/mask-defs/src/paint.rs133
-rw-r--r--rust/crates/mask-defs/src/shapes.rs260
-rw-r--r--rust/crates/mask-defs/src/split.rs135
-rw-r--r--rust/crates/mask-defs/src/text_mask.rs303
-rw-r--r--rust/crates/mask-defs/tests/render.rs372
-rw-r--r--rust/crates/renderer/Cargo.toml11
-rw-r--r--rust/crates/renderer/src/lib.rs5
-rw-r--r--rust/crates/renderer/src/node.rs123
-rw-r--r--rust/crates/renderer/src/scene_builder.rs285
-rw-r--r--rust/crates/renderer/tests/scene_builder.rs228
-rw-r--r--rust/crates/scene/Cargo.toml1
-rw-r--r--rust/crates/scene/src/factory.rs113
-rw-r--r--rust/crates/scene/src/lib.rs2
-rw-r--r--rust/crates/text/src/color.rs74
-rw-r--r--rust/crates/text/src/engine.rs194
-rw-r--r--rust/crates/text/src/layout.rs163
-rw-r--r--rust/crates/text/src/lib.rs21
-rw-r--r--rust/crates/text/src/params.rs166
-rw-r--r--rust/crates/text/src/raster.rs265
-rw-r--r--rust/crates/text/tests/fixtures/DejaVuSans-Bold.ttfbin0 -> 708876 bytes
-rw-r--r--rust/crates/text/tests/fixtures/DejaVuSans.ttfbin0 -> 759676 bytes
-rw-r--r--rust/crates/text/tests/layout.rs370
-rw-r--r--rust/crates/text/tests/raster.rs172
-rw-r--r--rust/crates/timeline/src/placement/apply.rs14
-rw-r--r--rust/crates/timeline/src/placement/mod.rs8
-rw-r--r--rust/crates/timeline/tests/compute_resize.rs375
-rw-r--r--rust/crates/timeline/tests/placement_resolve.rs624
-rw-r--r--rust/crates/timeline/tests/retime.rs111
-rw-r--r--rust/crates/timeline/tests/split.rs187
-rw-r--r--rust/crates/timeline/tests/update_pipeline.rs145
91 files changed, 13036 insertions, 231 deletions
diff --git a/rust/crates/animation/src/bezier.rs b/rust/crates/animation/src/bezier.rs
index 6bec252f..bd433c47 100644
--- a/rust/crates/animation/src/bezier.rs
+++ b/rust/crates/animation/src/bezier.rs
@@ -140,17 +140,19 @@ mod tests {
fn solve_progress_respects_flat_tangents() {
let mut left = key(0, 0.0);
left.right_handle = Some(CurveHandle {
- dt: MediaTime::from_ticks(0),
+ dt: MediaTime::from_ticks(50),
dv: 0.0,
});
let mut right = key(100, 100.0);
right.left_handle = Some(CurveHandle {
- dt: MediaTime::from_ticks(0),
+ dt: MediaTime::from_ticks(-50),
dv: 0.0,
});
let early = solve_bezier_progress_for_time(MediaTime::from_ticks(25), &left, &right);
+ let mid = solve_bezier_progress_for_time(MediaTime::from_ticks(50), &left, &right);
let late = solve_bezier_progress_for_time(MediaTime::from_ticks(75), &left, &right);
assert!(early < 0.25);
+ assert!((mid - 0.5).abs() < 1e-3);
assert!(late > 0.75);
}
}
diff --git a/rust/crates/animation/src/color.rs b/rust/crates/animation/src/color.rs
index 03f409a1..b486c869 100644
--- a/rust/crates/animation/src/color.rs
+++ b/rust/crates/animation/src/color.rs
@@ -45,7 +45,10 @@ pub fn parse_color_to_linear_rgba(color: &str) -> Option<LinearRgba> {
let r = parse_hex_digit(digits.next()?)?;
let g = parse_hex_digit(digits.next()?)?;
let b = parse_hex_digit(digits.next()?)?;
- let a = digits.next().map(parse_hex_digit).transpose()?.unwrap_or(1.0);
+ let a = match digits.next() {
+ Some(digit) => parse_hex_digit(digit)?,
+ None => 1.0,
+ };
(r, g, b, a)
}
6 | 8 => {
diff --git a/rust/crates/animation/src/effect_param_channel.rs b/rust/crates/animation/src/effect_param_channel.rs
new file mode 100644
index 00000000..cc949bda
--- /dev/null
+++ b/rust/crates/animation/src/effect_param_channel.rs
@@ -0,0 +1,125 @@
+use scene::{ElementAnimations, ParamValues};
+use time::MediaTime;
+
+use crate::keyframes::remove_element_keyframe;
+use crate::resolve::resolve_animation_path_value_at_time;
+
+pub const EFFECT_PARAM_PATH_PREFIX: &str = "effects.";
+pub const EFFECT_PARAM_PATH_SUFFIX: &str = ".params.";
+
+pub fn build_effect_param_path(effect_id: &str, param_key: &str) -> String {
+ format!("{EFFECT_PARAM_PATH_PREFIX}{effect_id}{EFFECT_PARAM_PATH_SUFFIX}{param_key}")
+}
+
+pub fn is_effect_param_path(property_path: &str) -> bool {
+ property_path.starts_with(EFFECT_PARAM_PATH_PREFIX)
+ && property_path.contains(EFFECT_PARAM_PATH_SUFFIX)
+}
+
+pub fn parse_effect_param_path(property_path: &str) -> Option<(String, String)> {
+ if !is_effect_param_path(property_path) {
+ return None;
+ }
+ let without_prefix = &property_path[EFFECT_PARAM_PATH_PREFIX.len()..];
+ let separator_index = without_prefix.find(EFFECT_PARAM_PATH_SUFFIX)?;
+ if separator_index == 0 {
+ return None;
+ }
+ let effect_id = &without_prefix[..separator_index];
+ let param_key = &without_prefix[separator_index + EFFECT_PARAM_PATH_SUFFIX.len()..];
+ if effect_id.is_empty() || param_key.is_empty() {
+ return None;
+ }
+ Some((effect_id.to_string(), param_key.to_string()))
+}
+
+pub fn resolve_effect_params_at_time(
+ effect_id: &str,
+ params: &ParamValues,
+ animations: Option<&ElementAnimations>,
+ local_time: MediaTime,
+) -> ParamValues {
+ let safe_local_time = local_time.max(MediaTime::ZERO);
+ let mut resolved = ParamValues::new();
+
+ for (param_key, static_value) in params {
+ let path = build_effect_param_path(effect_id, param_key);
+ let value = if animations.and_then(|a| a.get(&path)).is_some() {
+ resolve_animation_path_value_at_time(animations, &path, safe_local_time, static_value)
+ } else {
+ static_value.clone()
+ };
+ resolved.insert(param_key.clone(), value);
+ }
+
+ resolved
+}
+
+pub fn remove_effect_param_keyframe(
+ animations: Option<&ElementAnimations>,
+ effect_id: &str,
+ param_key: &str,
+ keyframe_id: &str,
+) -> Option<ElementAnimations> {
+ remove_element_keyframe(
+ animations,
+ &build_effect_param_path(effect_id, param_key),
+ keyframe_id,
+ )
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+ use scene::{AnimationChannel, ChannelData, ParamValue, ScalarAnimationKey, ScalarChannel, ScalarSegmentType, TangentMode};
+ use std::collections::BTreeMap;
+
+ #[test]
+ fn builds_and_parses_effect_param_paths() {
+ let path = build_effect_param_path("glow", "intensity");
+ assert_eq!(path, "effects.glow.params.intensity");
+ assert!(is_effect_param_path(&path));
+ assert!(!is_effect_param_path("params.blur"));
+ assert_eq!(
+ parse_effect_param_path(&path),
+ Some(("glow".to_string(), "intensity".to_string()))
+ );
+ assert_eq!(parse_effect_param_path("effects..params.x"), None);
+ assert_eq!(parse_effect_param_path("effects.glow.params."), None);
+ assert_eq!(parse_effect_param_path("opacity"), None);
+ }
+
+ #[test]
+ fn resolves_effect_params_and_removes_keyframes() {
+ let channel = AnimationChannel::Scalar(ScalarChannel {
+ keys: vec![ScalarAnimationKey {
+ id: "k1".to_string(),
+ time: MediaTime::from_ticks(0),
+ value: 3.0,
+ left_handle: None,
+ right_handle: None,
+ segment_to_next: ScalarSegmentType::Linear,
+ tangent_mode: TangentMode::Flat,
+ }],
+ extrapolation: None,
+ });
+ let mut animations: ElementAnimations = BTreeMap::new();
+ animations.insert(
+ "effects.glow.params.intensity".to_string(),
+ ChannelData::Single(channel),
+ );
+
+ let mut params: ParamValues = BTreeMap::new();
+ params.insert("intensity".to_string(), ParamValue::Number(1.0));
+ let resolved = resolve_effect_params_at_time(
+ "glow",
+ &params,
+ Some(&animations),
+ MediaTime::from_ticks(0),
+ );
+ assert_eq!(resolved.get("intensity"), Some(&ParamValue::Number(3.0)));
+
+ let after = remove_effect_param_keyframe(Some(&animations), "glow", "intensity", "k1");
+ assert!(after.is_none());
+ }
+}
diff --git a/rust/crates/animation/src/graph_channels.rs b/rust/crates/animation/src/graph_channels.rs
new file mode 100644
index 00000000..ea707a4f
--- /dev/null
+++ b/rust/crates/animation/src/graph_channels.rs
@@ -0,0 +1,184 @@
+use scene::{AnimationChannel, ChannelData, ElementAnimations, ScalarAnimationKey, ScalarChannel};
+use serde::{Deserialize, Serialize};
+
+use crate::channel_data::get_channel_entries_from_data;
+
+#[derive(Serialize, Deserialize, Clone, Copy, Debug, Eq, PartialEq)]
+#[serde(rename_all = "camelCase")]
+pub enum ChannelEasingMode {
+ Independent,
+ Shared,
+}
+
+#[derive(Clone, Debug, PartialEq)]
+pub struct ScalarGraphChannel {
+ pub property_path: String,
+ pub component_key: String,
+ pub channel: ScalarChannel,
+}
+
+#[derive(Clone, Debug, PartialEq)]
+pub struct EditableScalarChannels {
+ pub easing_mode: ChannelEasingMode,
+ pub channels: Vec<ScalarGraphChannel>,
+}
+
+#[derive(Clone, Debug, PartialEq)]
+pub struct ScalarGraphKeyframeContext {
+ pub property_path: String,
+ pub component_key: String,
+ pub channel: ScalarChannel,
+ pub keyframe: ScalarAnimationKey,
+ pub keyframe_index: usize,
+ pub previous_key: Option<ScalarAnimationKey>,
+ pub next_key: Option<ScalarAnimationKey>,
+}
+
+fn easing_mode_for_channel_data(data: &ChannelData) -> ChannelEasingMode {
+ match data {
+ ChannelData::Composite(components)
+ if ["r", "g", "b", "a"]
+ .iter()
+ .all(|key| components.contains_key(*key)) =>
+ {
+ ChannelEasingMode::Shared
+ }
+ _ => ChannelEasingMode::Independent,
+ }
+}
+
+pub fn get_editable_scalar_channels(
+ animations: Option<&ElementAnimations>,
+ property_path: &str,
+) -> Option<EditableScalarChannels> {
+ let data = animations?.get(property_path)?;
+
+ let channels = get_channel_entries_from_data(Some(data))
+ .into_iter()
+ .filter_map(|(component_key, channel)| match channel {
+ AnimationChannel::Scalar(scalar) => Some(ScalarGraphChannel {
+ property_path: property_path.to_string(),
+ component_key,
+ channel: scalar.clone(),
+ }),
+ AnimationChannel::Discrete(_) => None,
+ })
+ .collect();
+
+ Some(EditableScalarChannels {
+ easing_mode: easing_mode_for_channel_data(data),
+ channels,
+ })
+}
+
+pub fn get_editable_scalar_channel(
+ animations: Option<&ElementAnimations>,
+ property_path: &str,
+ component_key: &str,
+) -> Option<ScalarGraphChannel> {
+ get_editable_scalar_channels(animations, property_path)?
+ .channels
+ .into_iter()
+ .find(|channel| channel.component_key == component_key)
+}
+
+pub fn get_scalar_keyframe_context(
+ animations: Option<&ElementAnimations>,
+ property_path: &str,
+ component_key: &str,
+ keyframe_id: &str,
+) -> Option<ScalarGraphKeyframeContext> {
+ let graph_channel = get_editable_scalar_channel(animations, property_path, component_key)?;
+ let keyframe_index = graph_channel
+ .channel
+ .keys
+ .iter()
+ .position(|key| key.id == keyframe_id)?;
+
+ Some(ScalarGraphKeyframeContext {
+ property_path: graph_channel.property_path,
+ component_key: graph_channel.component_key,
+ keyframe: graph_channel.channel.keys[keyframe_index].clone(),
+ keyframe_index,
+ previous_key: keyframe_index
+ .checked_sub(1)
+ .and_then(|index| graph_channel.channel.keys.get(index))
+ .cloned(),
+ next_key: graph_channel.channel.keys.get(keyframe_index + 1).cloned(),
+ channel: graph_channel.channel,
+ })
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+ use scene::{ScalarSegmentType, TangentMode};
+ use std::collections::BTreeMap;
+ use time::MediaTime;
+
+ fn key(id: &str, time: i64) -> ScalarAnimationKey {
+ ScalarAnimationKey {
+ id: id.to_string(),
+ time: MediaTime::from_ticks(time),
+ value: 0.0,
+ left_handle: None,
+ right_handle: None,
+ segment_to_next: ScalarSegmentType::Linear,
+ tangent_mode: TangentMode::Flat,
+ }
+ }
+
+ fn scalar_channel(keys: Vec<ScalarAnimationKey>) -> AnimationChannel {
+ AnimationChannel::Scalar(ScalarChannel {
+ keys,
+ extrapolation: None,
+ })
+ }
+
+ #[test]
+ fn exposes_scalar_channels_for_editing() {
+ let mut animations: ElementAnimations = BTreeMap::new();
+ animations.insert(
+ "opacity".to_string(),
+ ChannelData::Single(scalar_channel(vec![key("a", 0), key("b", 10)])),
+ );
+ let editable = get_editable_scalar_channels(Some(&animations), "opacity").unwrap();
+ assert_eq!(editable.easing_mode, ChannelEasingMode::Independent);
+ assert_eq!(editable.channels.len(), 1);
+ assert_eq!(editable.channels[0].component_key, "value");
+
+ let context =
+ get_scalar_keyframe_context(Some(&animations), "opacity", "value", "b").unwrap();
+ assert_eq!(context.keyframe_index, 1);
+ assert_eq!(context.previous_key.as_ref().map(|key| key.id.as_str()), Some("a"));
+ assert_eq!(context.next_key, None);
+ }
+
+ #[test]
+ fn color_composites_share_easing() {
+ let mut components = BTreeMap::new();
+ for component_key in ["r", "g", "b", "a"] {
+ components.insert(component_key.to_string(), scalar_channel(vec![key("k", 0)]));
+ }
+ let mut animations: ElementAnimations = BTreeMap::new();
+ animations.insert("color".to_string(), ChannelData::Composite(components));
+ let editable = get_editable_scalar_channels(Some(&animations), "color").unwrap();
+ assert_eq!(editable.easing_mode, ChannelEasingMode::Shared);
+ assert_eq!(editable.channels.len(), 4);
+ }
+
+ #[test]
+ fn discrete_channels_are_not_editable_scalars() {
+ let channel = AnimationChannel::Discrete(scene::DiscreteChannel {
+ keys: vec![scene::DiscreteAnimationKey {
+ id: "d".to_string(),
+ time: MediaTime::from_ticks(0),
+ value: scene::DiscreteValue::Bool(true),
+ }],
+ });
+ let mut animations: ElementAnimations = BTreeMap::new();
+ animations.insert("params.visible".to_string(), ChannelData::Single(channel));
+ let editable = get_editable_scalar_channels(Some(&animations), "params.visible").unwrap();
+ assert!(editable.channels.is_empty());
+ }
+}
diff --git a/rust/crates/animation/src/graphic_param_channel.rs b/rust/crates/animation/src/graphic_param_channel.rs
new file mode 100644
index 00000000..776adde8
--- /dev/null
+++ b/rust/crates/animation/src/graphic_param_channel.rs
@@ -0,0 +1,102 @@
+use scene::{ElementAnimations, ParamValues};
+use time::MediaTime;
+
+use crate::params::ParamDefinition;
+use crate::resolve::resolve_animation_path_value_at_time;
+
+pub const GRAPHIC_PARAM_PATH_PREFIX: &str = "params.";
+
+pub fn build_graphic_param_path(param_key: &str) -> String {
+ format!("{GRAPHIC_PARAM_PATH_PREFIX}{param_key}")
+}
+
+pub fn is_graphic_param_path(property_path: &str) -> bool {
+ property_path.starts_with(GRAPHIC_PARAM_PATH_PREFIX)
+}
+
+pub fn parse_graphic_param_path(property_path: &str) -> Option<String> {
+ let param_key = property_path.strip_prefix(GRAPHIC_PARAM_PATH_PREFIX)?;
+ if param_key.is_empty() {
+ return None;
+ }
+ Some(param_key.to_string())
+}
+
+pub fn resolve_graphic_params_at_time(
+ params: &ParamValues,
+ definitions: &[ParamDefinition],
+ animations: Option<&ElementAnimations>,
+ local_time: MediaTime,
+) -> ParamValues {
+ let mut resolved = params.clone();
+ let safe_local_time = local_time.max(MediaTime::ZERO);
+
+ for param in definitions {
+ let path = build_graphic_param_path(param.key());
+ if animations.and_then(|a| a.get(&path)).is_none() {
+ continue;
+ }
+ let fallback_value = params
+ .get(param.key())
+ .cloned()
+ .unwrap_or_else(|| param.default_value());
+ resolved.insert(
+ param.key().to_string(),
+ resolve_animation_path_value_at_time(animations, &path, safe_local_time, &fallback_value),
+ );
+ }
+
+ resolved
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+ use scene::{AnimationChannel, ChannelData, ParamValue, ScalarAnimationKey, ScalarChannel, ScalarSegmentType, TangentMode};
+ use std::collections::BTreeMap;
+
+ #[test]
+ fn builds_and_parses_graphic_param_paths() {
+ assert_eq!(build_graphic_param_path("blur"), "params.blur");
+ assert!(is_graphic_param_path("params.blur"));
+ assert!(!is_graphic_param_path("effects.x.params.y"));
+ assert_eq!(parse_graphic_param_path("params.blur"), Some("blur".to_string()));
+ assert_eq!(parse_graphic_param_path("params."), None);
+ assert_eq!(parse_graphic_param_path("opacity"), None);
+ }
+
+ #[test]
+ fn resolves_animated_params_over_static_values() {
+ let channel = AnimationChannel::Scalar(ScalarChannel {
+ keys: vec![ScalarAnimationKey {
+ id: "a".to_string(),
+ time: MediaTime::from_ticks(0),
+ value: 5.0,
+ left_handle: None,
+ right_handle: None,
+ segment_to_next: ScalarSegmentType::Linear,
+ tangent_mode: TangentMode::Flat,
+ }],
+ extrapolation: None,
+ });
+ let mut animations: ElementAnimations = BTreeMap::new();
+ animations.insert("params.blur".to_string(), ChannelData::Single(channel));
+
+ let mut params: ParamValues = BTreeMap::new();
+ params.insert("blur".to_string(), ParamValue::Number(1.0));
+ params.insert("other".to_string(), ParamValue::Number(2.0));
+
+ let definitions = vec![
+ ParamDefinition::number("blur", "Blur", 0.0, 0.0, None, 1.0),
+ ParamDefinition::number("other", "Other", 0.0, 0.0, None, 1.0),
+ ];
+ let resolved = resolve_graphic_params_at_time(
+ &params,
+ &definitions,
+ Some(&animations),
+ MediaTime::from_ticks(0),
+ );
+ assert_eq!(resolved.get("blur"), Some(&ParamValue::Number(5.0)));
+ assert_eq!(resolved.get("other"), Some(&ParamValue::Number(2.0)));
+ }
+}
diff --git a/rust/crates/animation/src/interpolation.rs b/rust/crates/animation/src/interpolation.rs
index 85c0735d..2388fa52 100644
--- a/rust/crates/animation/src/interpolation.rs
+++ b/rust/crates/animation/src/interpolation.rs
@@ -223,7 +223,10 @@ pub fn channel_value_at_time(
time: MediaTime,
fallback_value: &ParamValue,
) -> ParamValue {
- let Some(channel) = channel.filter(|channel| !channel.keys.is_empty()) else {
+ let Some(channel) = channel.filter(|channel| match channel {
+ AnimationChannel::Scalar(scalar) => !scalar.keys.is_empty(),
+ AnimationChannel::Discrete(discrete) => !discrete.keys.is_empty(),
+ }) else {
return fallback_value.clone();
};
@@ -254,7 +257,7 @@ pub fn channel_value_at_time(
#[cfg(test)]
mod tests {
use super::*;
- use scene::{ChannelExtrapolation, TangentMode};
+ use scene::{ChannelExtrapolation, DiscreteAnimationKey, TangentMode};
fn scalar_key(id: &str, time: i64, value: f64, segment: ScalarSegmentType) -> ScalarAnimationKey {
ScalarAnimationKey {
@@ -360,7 +363,7 @@ mod tests {
scalar_key("b", 100, 100.0, ScalarSegmentType::Bezier),
]);
let value = scalar_channel_value_at_time(Some(&channel), at(50), 0.0);
- assert!((value - 50.0).abs() < 1e-6);
+ assert!((value - 50.0).abs() < 1e-3);
}
#[test]
@@ -379,7 +382,7 @@ mod tests {
let quarter = scalar_channel_value_at_time(Some(&channel), at(25), 0.0);
let mid = scalar_channel_value_at_time(Some(&channel), at(50), 0.0);
assert!(quarter < 25.0);
- assert!((mid - 50.0).abs() < 1e-6);
+ assert!((mid - 50.0).abs() < 1e-3);
}
#[test]
diff --git a/rust/crates/animation/src/keyframe_query.rs b/rust/crates/animation/src/keyframe_query.rs
new file mode 100644
index 00000000..fc516292
--- /dev/null
+++ b/rust/crates/animation/src/keyframe_query.rs
@@ -0,0 +1,308 @@
+use scene::{AnimationChannel, ChannelData, ElementAnimations, ParamValue};
+use time::MediaTime;
+
+use crate::channel_data::{get_channel_entries_from_data, is_animation_storage_key};
+use crate::color::format_linear_rgba;
+use crate::interpolation::{channel_value_at_time, scalar_segment_interpolation};
+use crate::path::is_animation_path;
+use crate::types::{AnimationInterpolation, ElementKeyframe};
+
+#[derive(Clone, Debug)]
+struct ChannelKeyframeMatch {
+ component_index: usize,
+ id: String,
+ time: MediaTime,
+ interpolation: AnimationInterpolation,
+}
+
+fn channel_keyframe_matches(data: Option<&ChannelData>) -> Vec<ChannelKeyframeMatch> {
+ get_channel_entries_from_data(data)
+ .into_iter()
+ .enumerate()
+ .flat_map(|(component_index, (_, channel))| {
+ let matches: Vec<ChannelKeyframeMatch> = match channel {
+ AnimationChannel::Scalar(scalar) => scalar
+ .keys
+ .iter()
+ .map(|key| ChannelKeyframeMatch {
+ component_index,
+ id: key.id.clone(),
+ time: key.time,
+ interpolation: scalar_segment_interpolation(key.segment_to_next),
+ })
+ .collect(),
+ AnimationChannel::Discrete(discrete) => discrete
+ .keys
+ .iter()
+ .map(|key| ChannelKeyframeMatch {
+ component_index,
+ id: key.id.clone(),
+ time: key.time,
+ interpolation: AnimationInterpolation::Hold,
+ })
+ .collect(),
+ };
+ matches
+ })
+ .collect()
+}
+
+fn unique_channel_keyframe_matches(data: Option<&ChannelData>) -> Vec<ChannelKeyframeMatch> {
+ let mut sorted = channel_keyframe_matches(data);
+ sorted.sort_by(|left, right| {
+ left.time
+ .cmp(&right.time)
+ .then(left.component_index.cmp(&right.component_index))
+ });
+
+ let mut unique: Vec<ChannelKeyframeMatch> = Vec::new();
+ for keyframe_match in sorted {
+ match unique.last() {
+ Some(previous) if previous.time == keyframe_match.time => {
+ if previous.component_index != 0 && keyframe_match.component_index == 0 {
+ *unique.last_mut().expect("last exists") = keyframe_match;
+ }
+ }
+ _ => unique.push(keyframe_match),
+ }
+ }
+ unique
+}
+
+fn preferred_channel_keyframe_match(
+ matches: Vec<ChannelKeyframeMatch>,
+) -> Option<ChannelKeyframeMatch> {
+ matches
+ .iter()
+ .find(|keyframe_match| keyframe_match.component_index == 0)
+ .cloned()
+ .or_else(|| matches.into_iter().next())
+}
+
+fn channel_fallback_value(channel: &AnimationChannel) -> ParamValue {
+ match channel {
+ AnimationChannel::Scalar(scalar) => scalar
+ .keys
+ .first()
+ .map(|key| ParamValue::Number(key.value))
+ .unwrap_or(ParamValue::Number(0.0)),
+ AnimationChannel::Discrete(discrete) => discrete
+ .keys
+ .first()
+ .map(|key| key.value.clone().into())
+ .unwrap_or(ParamValue::Bool(false)),
+ }
+}
+
+fn channel_value(channel: &AnimationChannel, time: MediaTime) -> ParamValue {
+ let fallback_value = channel_fallback_value(channel);
+ channel_value_at_time(Some(channel), time, &fallback_value)
+}
+
+fn composed_channel_data_value_at_time(
+ data: Option<&ChannelData>,
+ time: MediaTime,
+) -> Option<ParamValue> {
+ let entries = get_channel_entries_from_data(data);
+ if entries.is_empty() {
+ return None;
+ }
+ if entries.len() == 1 && entries[0].0 == "value" {
+ return Some(channel_value(entries[0].1, time));
+ }
+
+ let component_values: std::collections::BTreeMap<String, ParamValue> = entries
+ .iter()
+ .map(|(component_key, channel)| (component_key.clone(), channel_value(channel, time)))
+ .collect();
+ let (
+ Some(ParamValue::Number(r)),
+ Some(ParamValue::Number(g)),
+ Some(ParamValue::Number(b)),
+ Some(ParamValue::Number(a)),
+ ) = (
+ component_values.get("r"),
+ component_values.get("g"),
+ component_values.get("b"),
+ component_values.get("a"),
+ )
+ else {
+ return None;
+ };
+ Some(ParamValue::String(format_linear_rgba(
+ &crate::color::LinearRgba {
+ r: *r,
+ g: *g,
+ b: *b,
+ a: *a,
+ },
+ )))
+}
+
+fn to_element_keyframe(
+ data: Option<&ChannelData>,
+ property_path: &str,
+ keyframe_match: &ChannelKeyframeMatch,
+) -> Option<ElementKeyframe> {
+ let value = composed_channel_data_value_at_time(data, keyframe_match.time)?;
+ Some(ElementKeyframe {
+ property_path: property_path.to_string(),
+ id: keyframe_match.id.clone(),
+ time: keyframe_match.time,
+ value,
+ interpolation: keyframe_match.interpolation,
+ })
+}
+
+pub fn get_element_keyframes(animations: Option<&ElementAnimations>) -> Vec<ElementKeyframe> {
+ let Some(animations) = animations else {
+ return Vec::new();
+ };
+
+ animations
+ .iter()
+ .filter(|(key, _)| is_animation_storage_key(key))
+ .flat_map(|(property_path, data)| {
+ if !is_animation_path(property_path) {
+ return Vec::new();
+ }
+ unique_channel_keyframe_matches(Some(data))
+ .iter()
+ .filter_map(|keyframe_match| {
+ to_element_keyframe(Some(data), property_path, keyframe_match)
+ })
+ .collect()
+ })
+ .collect()
+}
+
+pub fn has_keyframes_for_path(
+ animations: Option<&ElementAnimations>,
+ property_path: &str,
+) -> bool {
+ get_channel_entries_from_data(animations.and_then(|a| a.get(property_path)))
+ .iter()
+ .any(|(_, channel)| {
+ match channel {
+ AnimationChannel::Scalar(scalar) => !scalar.keys.is_empty(),
+ AnimationChannel::Discrete(discrete) => !discrete.keys.is_empty(),
+ }
+ })
+}
+
+pub fn get_keyframe_at_time(
+ animations: Option<&ElementAnimations>,
+ property_path: &str,
+ time: MediaTime,
+) -> Option<ElementKeyframe> {
+ let data = animations?.get(property_path)?;
+ let keyframe_match = preferred_channel_keyframe_match(
+ channel_keyframe_matches(Some(data))
+ .into_iter()
+ .filter(|keyframe_match| keyframe_match.time == time)
+ .collect(),
+ )?;
+ to_element_keyframe(Some(data), property_path, &keyframe_match)
+}
+
+pub fn get_keyframe_by_id(
+ animations: Option<&ElementAnimations>,
+ property_path: &str,
+ keyframe_id: &str,
+) -> Option<ElementKeyframe> {
+ let data = animations?.get(property_path)?;
+ let keyframe_match = preferred_channel_keyframe_match(
+ channel_keyframe_matches(Some(data))
+ .into_iter()
+ .filter(|keyframe_match| keyframe_match.id == keyframe_id)
+ .collect(),
+ )?;
+ to_element_keyframe(Some(data), property_path, &keyframe_match)
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+ use scene::{ScalarAnimationKey, ScalarChannel, ScalarSegmentType, TangentMode};
+ use std::collections::BTreeMap;
+
+ fn key(id: &str, time: i64, value: f64) -> ScalarAnimationKey {
+ ScalarAnimationKey {
+ id: id.to_string(),
+ time: MediaTime::from_ticks(time),
+ value,
+ left_handle: None,
+ right_handle: None,
+ segment_to_next: ScalarSegmentType::Linear,
+ tangent_mode: TangentMode::Flat,
+ }
+ }
+
+ fn animations_with_channel(path: &str, channel: AnimationChannel) -> ElementAnimations {
+ let mut animations = BTreeMap::new();
+ animations.insert(path.to_string(), ChannelData::Single(channel));
+ animations
+ }
+
+ #[test]
+ fn lists_keyframes_sorted_by_time() {
+ let channel = AnimationChannel::Scalar(ScalarChannel {
+ keys: vec![key("b", 100, 1.0), key("a", 0, 0.0)],
+ extrapolation: None,
+ });
+ let animations = animations_with_channel("opacity", channel);
+ let keyframes = get_element_keyframes(Some(&animations));
+ assert_eq!(keyframes.len(), 2);
+ assert_eq!(keyframes[0].id, "a");
+ assert_eq!(keyframes[1].id, "b");
+ assert_eq!(keyframes[0].value, ParamValue::Number(0.0));
+ assert_eq!(keyframes[0].interpolation, AnimationInterpolation::Linear);
+ }
+
+ #[test]
+ fn ignores_non_animation_and_legacy_paths() {
+ let channel = AnimationChannel::Scalar(ScalarChannel {
+ keys: vec![key("a", 0, 0.0)],
+ extrapolation: None,
+ });
+ let animations = animations_with_channel("bindings", channel);
+ assert!(get_element_keyframes(Some(&animations)).is_empty());
+ }
+
+ #[test]
+ fn finds_keyframe_at_time_and_by_id() {
+ let channel = AnimationChannel::Scalar(ScalarChannel {
+ keys: vec![key("a", 0, 0.0), key("b", 100, 1.0)],
+ extrapolation: None,
+ });
+ let animations = animations_with_channel("opacity", channel);
+ let at_time = get_keyframe_at_time(Some(&animations), "opacity", MediaTime::from_ticks(100));
+ assert_eq!(at_time.as_ref().map(|k| k.id.as_str()), Some("b"));
+ assert!(get_keyframe_at_time(Some(&animations), "opacity", MediaTime::from_ticks(50)).is_none());
+ let by_id = get_keyframe_by_id(Some(&animations), "opacity", "a");
+ assert_eq!(by_id.as_ref().map(|k| k.time), Some(MediaTime::from_ticks(0)));
+ assert!(has_keyframes_for_path(Some(&animations), "opacity"));
+ assert!(!has_keyframes_for_path(Some(&animations), "volume"));
+ }
+
+ #[test]
+ fn composite_color_data_composes_hex_value() {
+ let component = |id: &str, time: i64, value: f64| {
+ AnimationChannel::Scalar(ScalarChannel {
+ keys: vec![key(id, time, value)],
+ extrapolation: None,
+ })
+ };
+ let mut components = BTreeMap::new();
+ components.insert("r".to_string(), component("kr", 0, 1.0));
+ components.insert("g".to_string(), component("kg", 0, 0.0));
+ components.insert("b".to_string(), component("kb", 0, 0.0));
+ components.insert("a".to_string(), component("ka", 0, 1.0));
+ let mut animations = BTreeMap::new();
+ animations.insert("color".to_string(), ChannelData::Composite(components));
+ let keyframes = get_element_keyframes(Some(&animations));
+ assert_eq!(keyframes.len(), 1);
+ assert_eq!(keyframes[0].id, "kr");
+ assert_eq!(keyframes[0].value, ParamValue::String("#ff0000".to_string()));
+ }
+}
diff --git a/rust/crates/animation/src/keyframes.rs b/rust/crates/animation/src/keyframes.rs
index 0ce938f9..74ecbd81 100644
--- a/rust/crates/animation/src/keyframes.rs
+++ b/rust/crates/animation/src/keyframes.rs
@@ -14,8 +14,8 @@ use crate::channel_data::{
use crate::channel_layout::{ChannelLayout, ChannelValueKind};
use crate::id::generate_uuid;
use crate::interpolation::{
- channel_value_at_time, is_scalar_channel, normalize_channel, normalize_discrete_channel,
- normalize_scalar_channel, scalar_segment_interpolation,
+ normalize_channel, normalize_discrete_channel, normalize_scalar_channel,
+ scalar_segment_interpolation,
};
use crate::types::{AnimationInterpolation, ScalarCurveKeyframePatch};
@@ -23,21 +23,15 @@ fn round_media_time(time: f64) -> MediaTime {
MediaTime::from_ticks(time.round() as i64)
}
-fn has_channel_keys(channel: Option<&AnimationChannel>) -> bool {
- channel.is_some_and(|channel| !channel.keys().is_empty())
-}
-
-trait ChannelKeys {
- fn keys(&self) -> usize;
+fn channel_key_count(channel: &AnimationChannel) -> usize {
+ match channel {
+ AnimationChannel::Scalar(scalar) => scalar.keys.len(),
+ AnimationChannel::Discrete(discrete) => discrete.keys.len(),
+ }
}
-impl ChannelKeys for AnimationChannel {
- fn keys(&self) -> usize {
- match self {
- AnimationChannel::Scalar(scalar) => scalar.keys.len(),
- AnimationChannel::Discrete(discrete) => discrete.keys.len(),
- }
- }
+fn has_channel_keys(channel: Option<&AnimationChannel>) -> bool {
+ channel.is_some_and(|channel| channel_key_count(channel) > 0)
}
fn has_channel_data(data: Option<&ChannelData>) -> bool {
@@ -296,7 +290,7 @@ pub fn get_channel<'a>(
animations: Option<&'a ElementAnimations>,
property_path: &str,
) -> Option<&'a AnimationChannel> {
- let data = animations?.get(property_path);
+ let data = animations?.get(property_path)?;
match data {
ChannelData::Single(channel) => Some(channel),
ChannelData::Composite(_) => get_channels_from_data(Some(data)).into_iter().next(),
@@ -313,13 +307,18 @@ pub fn upsert_path_keyframe(
channel_layout: &ChannelLayout,
coerce_value: impl Fn(&ParamValue) -> Option<ParamValue>,
) -> Option<ElementAnimations> {
- let coerced_value = coerce_value(value)?;
+ let coerced_value = match coerce_value(value) {
+ Some(coerced_value) => coerced_value,
+ None => return animations.cloned(),
+ };
let mut next_animations = animations.cloned().unwrap_or_default();
let current_data = animations.and_then(|a| a.get(property_path)).cloned();
let primary_channel = get_primary_channel_from_data(current_data.as_ref(), channel_layout);
let (target_id, target_time) = get_target_key_metadata(primary_channel, time, keyframe_id);
- let component_values = channel_layout.decompose(&coerced_value)?;
+ let Some(component_values) = channel_layout.decompose(&coerced_value) else {
+ return animations.cloned();
+ };
let mut next_data = current_data.clone();
for component in channel_layout.components() {
@@ -731,8 +730,8 @@ fn split_discrete_channel_at_time(
if include_split_boundary {
let has_boundary_on_left = left_keys.iter().any(|key| key.time == split_time);
let has_boundary_on_right = right_keys.iter().any(|key| key.time == MediaTime::ZERO);
- let boundary_value = discrete_value_at_time(
- &normalized,
+ let boundary_value = crate::interpolation::discrete_channel_value_at_time(
+ Some(&normalized),
split_time,
normalized.keys[0].value.clone(),
);
@@ -773,17 +772,6 @@ fn split_discrete_channel_at_time(
}
}
-fn discrete_value_at_time(
- channel: &DiscreteChannel,
- time: MediaTime,
- fallback_value: DiscreteValue,
-) -> DiscreteValue {
- let ParamValue::Bool(_) = ParamValue::Bool(false) else {
- unreachable!()
- };
- crate::interpolation::discrete_channel_value_at_time(Some(channel), time, fallback_value)
-}
-
fn build_split_scalar_result(
left_keys: Vec<ScalarAnimationKey>,
right_keys: Vec<ScalarAnimationKey>,
@@ -1087,11 +1075,15 @@ pub fn remove_element_keyframe(
property_path: &str,
keyframe_id: &str,
) -> Option<ElementAnimations> {
- let data = animations.and_then(|a| a.get(property_path))?;
+ let Some(data) = animations.and_then(|a| a.get(property_path)) else {
+ return animations.cloned();
+ };
let mut next_animations = animations.cloned().unwrap_or_default();
let next_data = match data {
- ChannelData::Single(channel) => remove_keyframe(Some(channel), keyframe_id),
+ ChannelData::Single(channel) => {
+ remove_keyframe(Some(channel), keyframe_id).map(ChannelData::Single)
+ }
ChannelData::Composite(components) => {
let mut next_data = Some(data.clone());
for (component_key, channel) in components {
@@ -1121,11 +1113,15 @@ pub fn retime_element_keyframe(
keyframe_id: &str,
time: MediaTime,
) -> Option<ElementAnimations> {
- let data = animations.and_then(|a| a.get(property_path))?;
+ let Some(data) = animations.and_then(|a| a.get(property_path)) else {
+ return animations.cloned();
+ };
let mut next_animations = animations.cloned().unwrap_or_default();
let next_data = match data {
- ChannelData::Single(channel) => retime_keyframe(Some(channel), keyframe_id, time),
+ ChannelData::Single(channel) => {
+ retime_keyframe(Some(channel), keyframe_id, time).map(ChannelData::Single)
+ }
ChannelData::Composite(components) => {
let mut next_data = Some(data.clone());
for (component_key, channel) in components {
@@ -1148,3 +1144,300 @@ pub fn retime_element_keyframe(
}
to_animation(next_animations)
}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+ use crate::channel_layout::number_channel_layout;
+ use scene::CurveHandle;
+
+ fn at(time: i64) -> MediaTime {
+ MediaTime::from_ticks(time)
+ }
+
+ fn scalar_key(id: &str, time: i64, value: f64) -> ScalarAnimationKey {
+ ScalarAnimationKey {
+ id: id.to_string(),
+ time: at(time),
+ value,
+ left_handle: None,
+ right_handle: None,
+ segment_to_next: ScalarSegmentType::Linear,
+ tangent_mode: TangentMode::Flat,
+ }
+ }
+
+ fn bezier_ease_key(id: &str, time: i64, value: f64, right: bool) -> ScalarAnimationKey {
+ let mut key = scalar_key(id, time, value);
+ key.segment_to_next = ScalarSegmentType::Bezier;
+ if right {
+ key.right_handle = Some(CurveHandle { dt: at(50), dv: 0.0 });
+ } else {
+ key.left_handle = Some(CurveHandle { dt: at(-50), dv: 0.0 });
+ }
+ key
+ }
+
+ fn scalar_channel(keys: Vec<ScalarAnimationKey>) -> ScalarChannel {
+ ScalarChannel { keys, extrapolation: None }
+ }
+
+ fn animations_with(path: &str, data: ChannelData) -> ElementAnimations {
+ let mut animations = ElementAnimations::new();
+ animations.insert(path.to_string(), data);
+ animations
+ }
+
+ #[test]
+ fn upsert_scalar_key_inserts_sorted() {
+ let channel = upsert_scalar_channel_key(None, at(100), 1.0, None, None, Some("b"));
+ let channel = upsert_scalar_channel_key(Some(&channel), at(0), 0.0, None, None, Some("a"));
+ assert_eq!(channel.keys.len(), 2);
+ assert_eq!(channel.keys[0].id, "a");
+ assert_eq!(channel.keys[1].id, "b");
+ }
+
+ #[test]
+ fn upsert_scalar_key_replaces_value_at_same_time_keeping_id() {
+ let channel = upsert_scalar_channel_key(None, at(50), 1.0, None, None, Some("a"));
+ let channel = upsert_scalar_channel_key(Some(&channel), at(50), 2.0, None, None, None);
+ assert_eq!(channel.keys.len(), 1);
+ assert_eq!(channel.keys[0].id, "a");
+ assert_eq!(channel.keys[0].value, 2.0);
+ }
+
+ #[test]
+ fn upsert_scalar_key_by_id_moves_time_and_preserves_segment() {
+ let channel = upsert_scalar_channel_key(
+ None, at(0), 0.0, Some(AnimationInterpolation::Bezier), None, Some("a"),
+ );
+ assert_eq!(channel.keys[0].segment_to_next, ScalarSegmentType::Bezier);
+ let channel = upsert_scalar_channel_key(Some(&channel), at(80), 5.0, None, None, Some("a"));
+ assert_eq!(channel.keys[0].time, at(80));
+ assert_eq!(channel.keys[0].segment_to_next, ScalarSegmentType::Bezier);
+ }
+
+ #[test]
+ fn new_key_defaults_to_component_interpolation() {
+ let channel = upsert_scalar_channel_key(
+ None, at(0), 0.0, None, Some(AnimationInterpolation::Hold), Some("a"),
+ );
+ assert_eq!(channel.keys[0].segment_to_next, ScalarSegmentType::Step);
+ }
+
+ #[test]
+ fn remove_keyframe_returns_none_when_empty() {
+ let channel = AnimationChannel::Scalar(scalar_channel(vec![scalar_key("a", 0, 1.0)]));
+ assert!(remove_keyframe(Some(&channel), "a").is_none());
+ let kept = remove_keyframe(Some(&channel), "missing").unwrap();
+ assert_eq!(channel_key_count(&kept), 1);
+ }
+
+ #[test]
+ fn retime_keyframe_resorts_channel() {
+ let channel = AnimationChannel::Scalar(scalar_channel(vec![
+ scalar_key("a", 0, 0.0),
+ scalar_key("b", 100, 1.0),
+ ]));
+ let retimed = retime_keyframe(Some(&channel), "a", at(200)).unwrap();
+ let AnimationChannel::Scalar(scalar) = retimed else { panic!("expected scalar") };
+ assert_eq!(scalar.keys[0].id, "b");
+ assert_eq!(scalar.keys[1].id, "a");
+ assert_eq!(scalar.keys[1].time, at(200));
+ }
+
+ #[test]
+ fn upsert_path_keyframe_creates_leaf_channel() {
+ let result = upsert_path_keyframe(
+ None, "opacity", at(0), &ParamValue::Number(0.5), None, Some("k1"),
+ &number_channel_layout(), |value| Some(value.clone()),
+ )
+ .unwrap();
+ let channel = get_channel(Some(&result), "opacity").unwrap();
+ assert_eq!(channel_key_count(channel), 1);
+ }
+
+ #[test]
+ fn upsert_path_keyframe_rejects_uncoercible_values() {
+ let result = upsert_path_keyframe(
+ None, "opacity", at(0), &ParamValue::Number(f64::NAN), None, None,
+ &number_channel_layout(), |_| None,
+ );
+ assert!(result.is_none());
+ }
+
+ #[test]
+ fn upsert_path_keyframe_decomposes_color_into_components() {
+ let result = upsert_path_keyframe(
+ None, "color", at(0), &ParamValue::String("#ff0000".to_string()), None, Some("k1"),
+ &crate::channel_layout::color_channel_layout(), |value| Some(value.clone()),
+ )
+ .unwrap();
+ let Some(ChannelData::Composite(components)) = result.get("color") else {
+ panic!("expected composite data")
+ };
+ assert_eq!(components.len(), 4);
+ for component_key in ["r", "g", "b", "a"] {
+ assert_eq!(channel_key_count(components.get(component_key).unwrap()), 1);
+ }
+ }
+
+ #[test]
+ fn split_linear_channel_inserts_boundary_keys() {
+ let channel = AnimationChannel::Scalar(scalar_channel(vec![
+ scalar_key("a", 0, 0.0),
+ scalar_key("b", 100, 10.0),
+ ]));
+ let animations = animations_with("opacity", ChannelData::Single(channel));
+ let (left, right) = split_animations_at_time_with_options(Some(&animations), at(50), true);
+
+ let left_channel = get_channel(left.as_ref(), "opacity").unwrap();
+ let AnimationChannel::Scalar(left_scalar) = left_channel else { panic!("expected scalar") };
+ assert_eq!(left_scalar.keys.len(), 2);
+ assert_eq!(left_scalar.keys[1].time, at(50));
+ assert_eq!(left_scalar.keys[1].value, 5.0);
+
+ let right_channel = get_channel(right.as_ref(), "opacity").unwrap();
+ let AnimationChannel::Scalar(right_scalar) = right_channel else { panic!("expected scalar") };
+ assert_eq!(right_scalar.keys.len(), 2);
+ assert_eq!(right_scalar.keys[0].time, at(0));
+ assert_eq!(right_scalar.keys[0].value, 5.0);
+ assert_eq!(right_scalar.keys[1].time, at(50));
+ assert_eq!(right_scalar.keys[1].value, 10.0);
+ }
+
+ #[test]
+ fn split_bezier_channel_preserves_curve_value_at_boundary() {
+ let channel = AnimationChannel::Scalar(scalar_channel(vec![
+ bezier_ease_key("a", 0, 0.0, true),
+ bezier_ease_key("b", 100, 100.0, false),
+ ]));
+ let ParamValue::Number(sampled) = crate::interpolation::channel_value_at_time(
+ Some(&channel), at(50), &ParamValue::Number(0.0),
+ ) else {
+ panic!("expected number")
+ };
+
+ let animations = animations_with("opacity", ChannelData::Single(channel));
+ let (left, right) = split_animations_at_time_with_options(Some(&animations), at(50), true);
+ let left_channel = get_channel(left.as_ref(), "opacity").unwrap();
+ let AnimationChannel::Scalar(left_scalar) = left_channel else { panic!("expected scalar") };
+ let boundary = left_scalar.keys.last().unwrap();
+ assert_eq!(boundary.time, at(50));
+ assert!((boundary.value - sampled).abs() < 1e-9);
+
+ let right_channel = get_channel(right.as_ref(), "opacity").unwrap();
+ let AnimationChannel::Scalar(right_scalar) = right_channel else { panic!("expected scalar") };
+ assert_eq!(right_scalar.keys[0].time, at(0));
+ assert!((right_scalar.keys[0].value - sampled).abs() < 1e-9);
+ }
+
+ #[test]
+ fn split_without_boundary_excludes_split_point_key() {
+ let channel = AnimationChannel::Scalar(scalar_channel(vec![
+ scalar_key("a", 0, 0.0),
+ scalar_key("b", 100, 10.0),
+ ]));
+ let animations = animations_with("opacity", ChannelData::Single(channel));
+ let (left, right) = split_animations_at_time_with_options(Some(&animations), at(50), false);
+ assert_eq!(channel_key_count(get_channel(left.as_ref(), "opacity").unwrap()), 1);
+ assert_eq!(channel_key_count(get_channel(right.as_ref(), "opacity").unwrap()), 1);
+ }
+
+ #[test]
+ fn split_discrete_channel_carries_current_value() {
+ let channel = AnimationChannel::Discrete(DiscreteChannel {
+ keys: vec![
+ DiscreteAnimationKey { id: "a".to_string(), time: at(0), value: DiscreteValue::String("x".to_string()) },
+ DiscreteAnimationKey { id: "b".to_string(), time: at(100), value: DiscreteValue::String("y".to_string()) },
+ ],
+ });
+ let animations = animations_with("params.label", ChannelData::Single(channel));
+ let (left, right) = split_animations_at_time_with_options(Some(&animations), at(50), true);
+ let left_channel = get_channel(left.as_ref(), "params.label").unwrap();
+ let AnimationChannel::Discrete(left_discrete) = left_channel else { panic!("expected discrete") };
+ assert_eq!(left_discrete.keys.len(), 2);
+ assert_eq!(left_discrete.keys[1].value, DiscreteValue::String("x".to_string()));
+ let right_channel = get_channel(right.as_ref(), "params.label").unwrap();
+ let AnimationChannel::Discrete(right_discrete) = right_channel else { panic!("expected discrete") };
+ assert_eq!(right_discrete.keys[0].value, DiscreteValue::String("x".to_string()));
+ }
+
+ #[test]
+ fn clamp_animations_drops_beyond_duration_and_keeps_boundary() {
+ let channel = AnimationChannel::Scalar(scalar_channel(vec![
+ scalar_key("a", 0, 0.0),
+ scalar_key("b", 100, 10.0),
+ ]));
+ let animations = animations_with("opacity", ChannelData::Single(channel));
+ let clamped = clamp_animations_to_duration(Some(&animations), at(40)).unwrap();
+ let channel = get_channel(Some(&clamped), "opacity").unwrap();
+ let AnimationChannel::Scalar(scalar) = channel else { panic!("expected scalar") };
+ assert_eq!(scalar.keys.len(), 2);
+ assert_eq!(scalar.keys[1].time, at(40));
+ assert_eq!(scalar.keys[1].value, 4.0);
+ assert!(clamp_animations_to_duration(Some(&animations), at(0)).is_none());
+ assert!(clamp_animations_to_duration(None, at(10)).is_none());
+ }
+
+ #[test]
+ fn clone_animations_regenerates_ids() {
+ let channel = AnimationChannel::Scalar(scalar_channel(vec![
+ scalar_key("a", 0, 0.0),
+ scalar_key("b", 100, 10.0),
+ ]));
+ let animations = animations_with("opacity", ChannelData::Single(channel));
+ let cloned = clone_animations(Some(&animations), true).unwrap();
+ let channel = get_channel(Some(&cloned), "opacity").unwrap();
+ let AnimationChannel::Scalar(scalar) = channel else { panic!("expected scalar") };
+ assert_eq!(scalar.keys.len(), 2);
+ assert_ne!(scalar.keys[0].id, "a");
+ assert_ne!(scalar.keys[1].id, "b");
+ assert_eq!(scalar.keys[0].time, at(0));
+
+ let kept = clone_animations(Some(&animations), false).unwrap();
+ let channel = get_channel(Some(&kept), "opacity").unwrap();
+ let AnimationChannel::Scalar(scalar) = channel else { panic!("expected scalar") };
+ assert_eq!(scalar.keys[0].id, "a");
+ assert!(clone_animations(None, true).is_none());
+ }
+
+ #[test]
+ fn update_scalar_keyframe_curve_patches_handles() {
+ let channel = AnimationChannel::Scalar(scalar_channel(vec![
+ scalar_key("a", 0, 0.0),
+ scalar_key("b", 100, 10.0),
+ ]));
+ let animations = animations_with("opacity", ChannelData::Single(channel));
+ let patch = ScalarCurveKeyframePatch {
+ left_handle: None,
+ right_handle: Some(Some(CurveHandle { dt: at(10), dv: 2.0 })),
+ segment_to_next: Some(ScalarSegmentType::Bezier),
+ tangent_mode: Some(TangentMode::Broken),
+ };
+ let updated = update_scalar_keyframe_curve(Some(&animations), "opacity", "value", "a", &patch).unwrap();
+ let channel = get_channel(Some(&updated), "opacity").unwrap();
+ let AnimationChannel::Scalar(scalar) = channel else { panic!("expected scalar") };
+ assert_eq!(scalar.keys[0].segment_to_next, ScalarSegmentType::Bezier);
+ assert_eq!(scalar.keys[0].tangent_mode, TangentMode::Broken);
+ assert_eq!(scalar.keys[0].right_handle.unwrap().dv, 2.0);
+ }
+
+ #[test]
+ fn remove_element_keyframe_across_composite_components() {
+ let mut components = BTreeMap::new();
+ components.insert("r".to_string(), AnimationChannel::Scalar(scalar_channel(vec![scalar_key("k", 0, 1.0)])));
+ components.insert("g".to_string(), AnimationChannel::Scalar(scalar_channel(vec![scalar_key("k", 0, 0.0)])));
+ let animations = animations_with("color", ChannelData::Composite(components));
+ assert!(remove_element_keyframe(Some(&animations), "color", "k").is_none());
+ }
+
+ #[test]
+ fn empty_channels_are_filtered_by_to_animation() {
+ let animations = animations_with(
+ "opacity",
+ ChannelData::Single(AnimationChannel::Scalar(scalar_channel(Vec::new()))),
+ );
+ assert!(to_animation(animations).is_none());
+ }
+}
diff --git a/rust/crates/animation/src/lib.rs b/rust/crates/animation/src/lib.rs
index 8b137891..01f64b57 100644
--- a/rust/crates/animation/src/lib.rs
+++ b/rust/crates/animation/src/lib.rs
@@ -1 +1,75 @@
+mod bezier;
+mod channel_data;
+mod channel_layout;
+mod color;
+mod effect_param_channel;
+mod graph_channels;
+mod graphic_param_channel;
+mod id;
+mod interpolation;
+mod keyframe_query;
+mod keyframes;
+mod params;
+mod path;
+mod property_groups;
+mod resolve;
+mod types;
+mod values;
+pub use bezier::{
+ BEZIER_SOLVE_ITERATIONS, BezierHandle, bezier_point, default_left_handle,
+ default_right_handle, segment_handles, solve_bezier_progress_for_time,
+};
+pub use channel_data::{
+ get_channel_entries_from_data, get_channels_from_data, is_animation_storage_key,
+ ordered_composite_entries,
+};
+pub use channel_layout::{
+ ChannelComponentDefinition, ChannelLayout, ChannelValueKind, boolean_channel_layout,
+ color_channel_layout, number_channel_layout, string_channel_layout,
+};
+pub use color::{LinearRgba, format_linear_rgba, parse_color_to_linear_rgba};
+pub use effect_param_channel::{
+ EFFECT_PARAM_PATH_PREFIX, EFFECT_PARAM_PATH_SUFFIX, build_effect_param_path,
+ is_effect_param_path, parse_effect_param_path, remove_effect_param_keyframe,
+ resolve_effect_params_at_time,
+};
+pub use graph_channels::{
+ ChannelEasingMode, EditableScalarChannels, ScalarGraphChannel, ScalarGraphKeyframeContext,
+ get_editable_scalar_channel, get_editable_scalar_channels, get_scalar_keyframe_context,
+};
+pub use graphic_param_channel::{
+ GRAPHIC_PARAM_PATH_PREFIX, build_graphic_param_path, is_graphic_param_path,
+ parse_graphic_param_path, resolve_graphic_params_at_time,
+};
+pub use id::generate_uuid;
+pub use interpolation::{
+ channel_value_at_time, discrete_channel_value_at_time, is_scalar_channel, normalize_channel,
+ normalize_discrete_channel, normalize_scalar_channel, scalar_channel_value_at_time,
+ scalar_segment_interpolation,
+};
+pub use keyframe_query::{
+ get_element_keyframes, get_keyframe_at_time, get_keyframe_by_id, has_keyframes_for_path,
+};
+pub use keyframes::{
+ SplitChannelResult, clamp_animations_to_duration, clone_animations, get_channel,
+ remove_element_keyframe, remove_keyframe, retime_element_keyframe, retime_keyframe,
+ set_binding_component_channel, set_channel, split_animations_at_time,
+ split_animations_at_time_with_options, to_animation, update_scalar_keyframe_curve,
+ upsert_discrete_channel_key, upsert_keyframe, upsert_path_keyframe, upsert_scalar_channel_key,
+};
+pub use params::{
+ NumberParamDefinition, ParamDefinition, ParamValueKind, SelectOption, coerce_param_value,
+ get_param_channel_layout, get_param_default_interpolation, get_param_numeric_range,
+ get_param_value_kind, snap_to_step,
+};
+pub use path::is_animation_path;
+pub use property_groups::{
+ ANIMATION_PROPERTY_GROUPS, GroupKeyframeRef, animation_property_group_paths,
+ get_group_keyframes_at_time, has_group_keyframe_at_time, is_animation_property_path,
+};
+pub use resolve::{get_element_local_time, resolve_animation_path_value_at_time};
+pub use types::{
+ AnimationInterpolation, ElementKeyframe, NumericSpec, ScalarCurveKeyframePatch,
+};
+pub use values::{resolve_color_at_time, resolve_number_at_time, resolve_opacity_at_time};
diff --git a/rust/crates/animation/src/params.rs b/rust/crates/animation/src/params.rs
new file mode 100644
index 00000000..c677e392
--- /dev/null
+++ b/rust/crates/animation/src/params.rs
@@ -0,0 +1,445 @@
+use scene::ParamValue;
+
+use crate::channel_layout::{
+ ChannelLayout, boolean_channel_layout, color_channel_layout, number_channel_layout,
+ string_channel_layout,
+};
+use crate::types::{AnimationInterpolation, NumericSpec};
+
+#[derive(Clone, Debug, PartialEq)]
+pub struct NumberParamDefinition {
+ pub key: String,
+ pub label: String,
+ pub default: f64,
+ pub min: f64,
+ pub max: Option<f64>,
+ pub step: f64,
+ pub channels: Option<ChannelLayout>,
+}
+
+#[derive(Clone, Debug, PartialEq)]
+pub struct BooleanParamDefinition {
+ pub key: String,
+ pub label: String,
+ pub default: bool,
+ pub channels: Option<ChannelLayout>,
+}
+
+#[derive(Clone, Debug, PartialEq)]
+pub struct ColorParamDefinition {
+ pub key: String,
+ pub label: String,
+ pub default: String,
+ pub channels: Option<ChannelLayout>,
+}
+
+#[derive(Clone, Debug, PartialEq)]
+pub struct SelectOption {
+ pub value: String,
+ pub label: String,
+}
+
+#[derive(Clone, Debug, PartialEq)]
+pub struct SelectParamDefinition {
+ pub key: String,
+ pub label: String,
+ pub default: String,
+ pub options: Vec<SelectOption>,
+ pub channels: Option<ChannelLayout>,
+}
+
+#[derive(Clone, Debug, PartialEq)]
+pub struct TextParamDefinition {
+ pub key: String,
+ pub label: String,
+ pub default: String,
+ pub channels: Option<ChannelLayout>,
+}
+
+#[derive(Clone, Debug, PartialEq)]
+pub struct FontParamDefinition {
+ pub key: String,
+ pub label: String,
+ pub default: String,
+ pub channels: Option<ChannelLayout>,
+}
+
+#[derive(Clone, Debug, PartialEq)]
+pub enum ParamDefinition {
+ Number(NumberParamDefinition),
+ Boolean(BooleanParamDefinition),
+ Color(ColorParamDefinition),
+ Select(SelectParamDefinition),
+ Text(TextParamDefinition),
+ Font(FontParamDefinition),
+}
+
+impl ParamDefinition {
+ pub fn number(
+ key: &str,
+ label: &str,
+ default: f64,
+ min: f64,
+ max: Option<f64>,
+ step: f64,
+ ) -> Self {
+ ParamDefinition::Number(NumberParamDefinition {
+ key: key.to_string(),
+ label: label.to_string(),
+ default,
+ min,
+ max,
+ step,
+ channels: None,
+ })
+ }
+
+ pub fn boolean(key: &str, label: &str, default: bool) -> Self {
+ ParamDefinition::Boolean(BooleanParamDefinition {
+ key: key.to_string(),
+ label: label.to_string(),
+ default,
+ channels: None,
+ })
+ }
+
+ pub fn color(key: &str, label: &str, default: &str) -> Self {
+ ParamDefinition::Color(ColorParamDefinition {
+ key: key.to_string(),
+ label: label.to_string(),
+ default: default.to_string(),
+ channels: None,
+ })
+ }
+
+ pub fn select(key: &str, label: &str, default: &str, options: Vec<SelectOption>) -> Self {
+ ParamDefinition::Select(SelectParamDefinition {
+ key: key.to_string(),
+ label: label.to_string(),
+ default: default.to_string(),
+ options,
+ channels: None,
+ })
+ }
+
+ pub fn key(&self) -> &str {
+ match self {
+ ParamDefinition::Number(param) => &param.key,
+ ParamDefinition::Boolean(param) => &param.key,
+ ParamDefinition::Color(param) => &param.key,
+ ParamDefinition::Select(param) => &param.key,
+ ParamDefinition::Text(param) => &param.key,
+ ParamDefinition::Font(param) => &param.key,
+ }
+ }
+
+ pub fn default_value(&self) -> ParamValue {
+ match self {
+ ParamDefinition::Number(param) => ParamValue::Number(param.default),
+ ParamDefinition::Boolean(param) => ParamValue::Bool(param.default),
+ ParamDefinition::Color(param) => ParamValue::String(param.default.clone()),
+ ParamDefinition::Select(param) => ParamValue::String(param.default.clone()),
+ ParamDefinition::Text(param) => ParamValue::String(param.default.clone()),
+ ParamDefinition::Font(param) => ParamValue::String(param.default.clone()),
+ }
+ }
+}
+
+#[derive(Clone, Copy, Debug, Eq, PartialEq)]
+pub enum ParamValueKind {
+ Number,
+ Color,
+ Discrete,
+}
+
+pub fn get_param_channel_layout(param: &ParamDefinition) -> ChannelLayout {
+ match param {
+ ParamDefinition::Number(param) => param
+ .channels
+ .clone()
+ .unwrap_or_else(number_channel_layout),
+ ParamDefinition::Boolean(param) => param
+ .channels
+ .clone()
+ .unwrap_or_else(boolean_channel_layout),
+ ParamDefinition::Color(param) => param.channels.clone().unwrap_or_else(color_channel_layout),
+ ParamDefinition::Select(param) => param
+ .channels
+ .clone()
+ .unwrap_or_else(string_channel_layout),
+ ParamDefinition::Text(param) => param
+ .channels
+ .clone()
+ .unwrap_or_else(string_channel_layout),
+ ParamDefinition::Font(param) => param
+ .channels
+ .clone()
+ .unwrap_or_else(string_channel_layout),
+ }
+}
+
+pub fn get_param_value_kind(param: &ParamDefinition) -> ParamValueKind {
+ let layout = get_param_channel_layout(param);
+ match &layout {
+ ChannelLayout::Composite(_) => ParamValueKind::Color,
+ ChannelLayout::Leaf(component) => match component.value_kind {
+ crate::channel_layout::ChannelValueKind::Scalar => ParamValueKind::Number,
+ crate::channel_layout::ChannelValueKind::Discrete => ParamValueKind::Discrete,
+ },
+ }
+}
+
+pub fn get_param_default_interpolation(param: &ParamDefinition) -> AnimationInterpolation {
+ let layout = get_param_channel_layout(param);
+ layout
+ .components()
+ .first()
+ .map(|component| component.default_interpolation)
+ .unwrap_or(AnimationInterpolation::Linear)
+}
+
+pub fn get_param_numeric_range(param: &ParamDefinition) -> Option<NumericSpec> {
+ let ParamDefinition::Number(param) = param else {
+ return None;
+ };
+ Some(NumericSpec {
+ min: Some(param.min),
+ max: param.max,
+ step: Some(param.step),
+ })
+}
+
+fn fraction_digits_for_step(step: f64) -> usize {
+ if step != 0.0 && step.abs() < 1e-6 {
+ let exponential = format!("{step:e}");
+ if let Some((_, exponent)) = exponential.split_once('e') {
+ if let Ok(value) = exponent.parse::<i32>() {
+ return value.unsigned_abs() as usize;
+ }
+ }
+ return 0;
+ }
+ let normalized = format!("{step}");
+ normalized
+ .split_once('.')
+ .map(|(_, fraction)| fraction.len())
+ .unwrap_or(0)
+}
+
+pub fn snap_to_step(value: f64, step: f64) -> f64 {
+ if step <= 0.0 {
+ return value;
+ }
+ let snapped = (value / step).round() * step;
+ let digits = fraction_digits_for_step(step);
+ format!("{snapped:.digits$}").parse().unwrap_or(snapped)
+}
+
+pub fn coerce_param_value(param: &ParamDefinition, value: &ParamValue) -> Option<ParamValue> {
+ match param {
+ ParamDefinition::Number(param) => {
+ let ParamValue::Number(number) = value else {
+ return None;
+ };
+ if number.is_nan() {
+ return None;
+ }
+ let stepped = snap_to_step(*number, param.step);
+ let max = param.max.unwrap_or(f64::INFINITY);
+ Some(ParamValue::Number(stepped.min(max).max(param.min)))
+ }
+ ParamDefinition::Boolean(_) => match value {
+ ParamValue::Bool(value) => Some(ParamValue::Bool(*value)),
+ _ => None,
+ },
+ ParamDefinition::Color(_) | ParamDefinition::Text(_) | ParamDefinition::Font(_) => {
+ match value {
+ ParamValue::String(value) => Some(ParamValue::String(value.clone())),
+ _ => None,
+ }
+ }
+ ParamDefinition::Select(param) => match value {
+ ParamValue::String(value)
+ if param.options.iter().any(|option| &option.value == value) =>
+ {
+ Some(ParamValue::String(value.clone()))
+ }
+ _ => None,
+ },
+ }
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+
+ fn intensity_param() -> ParamDefinition {
+ ParamDefinition::number("intensity", "Intensity", 0.0, 0.0, Some(1.0), 0.25)
+ }
+
+ fn blend_param() -> ParamDefinition {
+ ParamDefinition::select(
+ "blend",
+ "Blend",
+ "normal",
+ vec![
+ SelectOption {
+ value: "normal".to_string(),
+ label: "Normal".to_string(),
+ },
+ SelectOption {
+ value: "multiply".to_string(),
+ label: "Multiply".to_string(),
+ },
+ ],
+ )
+ }
+
+ #[test]
+ fn snaps_and_clamps_number_params() {
+ assert_eq!(
+ coerce_param_value(&intensity_param(), &ParamValue::Number(0.62)),
+ Some(ParamValue::Number(0.5))
+ );
+ assert_eq!(
+ coerce_param_value(&intensity_param(), &ParamValue::Number(1.2)),
+ Some(ParamValue::Number(1.0))
+ );
+ }
+
+ #[test]
+ fn rejects_nan_and_non_number_values() {
+ assert_eq!(
+ coerce_param_value(&intensity_param(), &ParamValue::Number(f64::NAN)),
+ None
+ );
+ assert_eq!(
+ coerce_param_value(&intensity_param(), &ParamValue::String("0.5".to_string())),
+ None
+ );
+ assert_eq!(
+ coerce_param_value(&intensity_param(), &ParamValue::Bool(true)),
+ None
+ );
+ }
+
+ #[test]
+ fn passthrough_with_step_zero_guard() {
+ let param = ParamDefinition::number("x", "X", 0.0, 0.0, None, 0.0);
+ assert_eq!(
+ coerce_param_value(&param, &ParamValue::Number(0.123)),
+ Some(ParamValue::Number(0.123))
+ );
+ }
+
+ #[test]
+ fn accepts_valid_select_values() {
+ assert_eq!(
+ coerce_param_value(&blend_param(), &ParamValue::String("normal".to_string())),
+ Some(ParamValue::String("normal".to_string()))
+ );
+ assert_eq!(
+ coerce_param_value(&blend_param(), &ParamValue::String("multiply".to_string())),
+ Some(ParamValue::String("multiply".to_string()))
+ );
+ }
+
+ #[test]
+ fn rejects_select_values_outside_options() {
+ assert_eq!(
+ coerce_param_value(&blend_param(), &ParamValue::String("screen".to_string())),
+ None
+ );
+ }
+
+ #[test]
+ fn rejects_non_string_select_values() {
+ assert_eq!(
+ coerce_param_value(&blend_param(), &ParamValue::Number(42.0)),
+ None
+ );
+ assert_eq!(coerce_param_value(&blend_param(), &ParamValue::Bool(true)), None);
+ }
+
+ #[test]
+ fn boolean_params_accept_booleans_and_reject_other_types() {
+ let param = ParamDefinition::boolean("visible", "Visible", true);
+ assert_eq!(
+ coerce_param_value(&param, &ParamValue::Bool(true)),
+ Some(ParamValue::Bool(true))
+ );
+ assert_eq!(
+ coerce_param_value(&param, &ParamValue::Bool(false)),
+ Some(ParamValue::Bool(false))
+ );
+ assert_eq!(coerce_param_value(&param, &ParamValue::Number(1.0)), None);
+ assert_eq!(
+ coerce_param_value(&param, &ParamValue::String("true".to_string())),
+ None
+ );
+ }
+
+ #[test]
+ fn color_params_accept_strings_and_reject_other_types() {
+ let param = ParamDefinition::color("fill", "Fill", "#ffffff");
+ assert_eq!(
+ coerce_param_value(&param, &ParamValue::String("#ff0000".to_string())),
+ Some(ParamValue::String("#ff0000".to_string()))
+ );
+ assert_eq!(
+ coerce_param_value(&param, &ParamValue::Number(0xff0000 as f64)),
+ None
+ );
+ assert_eq!(coerce_param_value(&param, &ParamValue::Bool(true)), None);
+ }
+
+ #[test]
+ fn value_kind_maps_param_type_to_binding_kind() {
+ let number = ParamDefinition::number("n", "N", 0.0, 0.0, None, 1.0);
+ assert_eq!(get_param_value_kind(&number), ParamValueKind::Number);
+ let color = ParamDefinition::color("c", "C", "#fff");
+ assert_eq!(get_param_value_kind(&color), ParamValueKind::Color);
+ let boolean = ParamDefinition::boolean("b", "B", false);
+ assert_eq!(get_param_value_kind(&boolean), ParamValueKind::Discrete);
+ assert_eq!(get_param_value_kind(&blend_param()), ParamValueKind::Discrete);
+ }
+
+ #[test]
+ fn default_interpolation_is_linear_for_continuous_hold_for_discrete() {
+ let number = ParamDefinition::number("n", "N", 0.0, 0.0, None, 1.0);
+ assert_eq!(
+ get_param_default_interpolation(&number),
+ AnimationInterpolation::Linear
+ );
+ let color = ParamDefinition::color("c", "C", "#fff");
+ assert_eq!(
+ get_param_default_interpolation(&color),
+ AnimationInterpolation::Linear
+ );
+ let boolean = ParamDefinition::boolean("b", "B", false);
+ assert_eq!(
+ get_param_default_interpolation(&boolean),
+ AnimationInterpolation::Hold
+ );
+ assert_eq!(
+ get_param_default_interpolation(&blend_param()),
+ AnimationInterpolation::Hold
+ );
+ }
+
+ #[test]
+ fn numeric_range_returns_spec_for_number_params_only() {
+ assert_eq!(
+ get_param_numeric_range(&intensity_param()),
+ Some(NumericSpec {
+ min: Some(0.0),
+ max: Some(1.0),
+ step: Some(0.25),
+ })
+ );
+ let color = ParamDefinition::color("c", "C", "#fff");
+ assert_eq!(get_param_numeric_range(&color), None);
+ let boolean = ParamDefinition::boolean("b", "B", false);
+ assert_eq!(get_param_numeric_range(&boolean), None);
+ }
+}
diff --git a/rust/crates/animation/src/path.rs b/rust/crates/animation/src/path.rs
new file mode 100644
index 00000000..56256c61
--- /dev/null
+++ b/rust/crates/animation/src/path.rs
@@ -0,0 +1,23 @@
+use crate::effect_param_channel::is_effect_param_path;
+use crate::graphic_param_channel::is_graphic_param_path;
+use crate::property_groups::is_animation_property_path;
+
+pub fn is_animation_path(property_path: &str) -> bool {
+ is_animation_property_path(property_path)
+ || is_graphic_param_path(property_path)
+ || is_effect_param_path(property_path)
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+
+ #[test]
+ fn classifies_animation_paths() {
+ assert!(is_animation_path("opacity"));
+ assert!(is_animation_path("params.blur"));
+ assert!(is_animation_path("effects.glow.params.intensity"));
+ assert!(!is_animation_path("transform"));
+ assert!(!is_animation_path("bindings"));
+ }
+}
diff --git a/rust/crates/animation/src/property_groups.rs b/rust/crates/animation/src/property_groups.rs
new file mode 100644
index 00000000..fd639905
--- /dev/null
+++ b/rust/crates/animation/src/property_groups.rs
@@ -0,0 +1,110 @@
+use scene::{ANIMATION_PROPERTY_PATHS, ElementAnimations};
+use time::MediaTime;
+
+use crate::keyframe_query::get_keyframe_at_time;
+
+pub const ANIMATION_PROPERTY_GROUPS: &[(&str, &[&str])] = &[(
+ "transform.scale",
+ &["transform.scaleX", "transform.scaleY"],
+)];
+
+#[derive(Clone, Debug, PartialEq)]
+pub struct GroupKeyframeRef {
+ pub property_path: &'static str,
+ pub keyframe_id: String,
+}
+
+pub fn animation_property_group_paths(group: &str) -> Option<&'static [&'static str]> {
+ ANIMATION_PROPERTY_GROUPS
+ .iter()
+ .find(|(name, _)| *name == group)
+ .map(|(_, paths)| *paths)
+}
+
+pub fn get_group_keyframes_at_time(
+ animations: Option<&ElementAnimations>,
+ group: &str,
+ time: MediaTime,
+) -> Vec<GroupKeyframeRef> {
+ let Some(paths) = animation_property_group_paths(group) else {
+ return Vec::new();
+ };
+ paths
+ .iter()
+ .filter_map(|property_path| {
+ get_keyframe_at_time(animations, property_path, time).map(|keyframe| {
+ GroupKeyframeRef {
+ property_path,
+ keyframe_id: keyframe.id,
+ }
+ })
+ })
+ .collect()
+}
+
+pub fn has_group_keyframe_at_time(
+ animations: Option<&ElementAnimations>,
+ group: &str,
+ time: MediaTime,
+) -> bool {
+ !get_group_keyframes_at_time(animations, group, time).is_empty()
+}
+
+pub fn is_animation_property_path(property_path: &str) -> bool {
+ ANIMATION_PROPERTY_PATHS.contains(&property_path)
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+ use scene::{
+ AnimationChannel, ChannelData, ScalarAnimationKey, ScalarChannel, ScalarSegmentType,
+ TangentMode,
+ };
+ use std::collections::BTreeMap;
+
+ #[test]
+ fn finds_group_keyframes_across_group_paths() {
+ let key = |id: &str| ScalarAnimationKey {
+ id: id.to_string(),
+ time: MediaTime::from_ticks(0),
+ value: 1.0,
+ left_handle: None,
+ right_handle: None,
+ segment_to_next: ScalarSegmentType::Linear,
+ tangent_mode: TangentMode::Flat,
+ };
+ let mut animations: ElementAnimations = BTreeMap::new();
+ animations.insert(
+ "transform.scaleX".to_string(),
+ ChannelData::Single(AnimationChannel::Scalar(ScalarChannel {
+ keys: vec![key("kx")],
+ extrapolation: None,
+ })),
+ );
+ let refs = get_group_keyframes_at_time(
+ Some(&animations),
+ "transform.scale",
+ MediaTime::from_ticks(0),
+ );
+ assert_eq!(refs.len(), 1);
+ assert_eq!(refs[0].property_path, "transform.scaleX");
+ assert!(has_group_keyframe_at_time(
+ Some(&animations),
+ "transform.scale",
+ MediaTime::from_ticks(0)
+ ));
+ assert!(!has_group_keyframe_at_time(
+ Some(&animations),
+ "transform.scale",
+ MediaTime::from_ticks(10)
+ ));
+ }
+
+ #[test]
+ fn recognizes_animation_property_paths() {
+ assert!(is_animation_property_path("opacity"));
+ assert!(is_animation_property_path("transform.rotate"));
+ assert!(!is_animation_property_path("params.blur"));
+ }
+}
diff --git a/rust/crates/animation/src/resolve.rs b/rust/crates/animation/src/resolve.rs
new file mode 100644
index 00000000..ccb9dbad
--- /dev/null
+++ b/rust/crates/animation/src/resolve.rs
@@ -0,0 +1,182 @@
+use scene::{ChannelData, ElementAnimations, ParamValue};
+use time::MediaTime;
+
+use crate::color::{LinearRgba, format_linear_rgba, parse_color_to_linear_rgba};
+use crate::interpolation::channel_value_at_time;
+
+pub fn get_element_local_time(
+ timeline_time: MediaTime,
+ element_start_time: MediaTime,
+ element_duration: MediaTime,
+) -> MediaTime {
+ let local_time = timeline_time - element_start_time;
+ if local_time <= MediaTime::ZERO {
+ return MediaTime::ZERO;
+ }
+ if local_time >= element_duration {
+ return element_duration;
+ }
+ local_time
+}
+
+pub fn resolve_animation_path_value_at_time(
+ animations: Option<&ElementAnimations>,
+ property_path: &str,
+ local_time: MediaTime,
+ fallback_value: &ParamValue,
+) -> ParamValue {
+ let Some(data) = animations.and_then(|a| a.get(property_path)) else {
+ return fallback_value.clone();
+ };
+
+ match data {
+ ChannelData::Single(channel) => match channel_value_at_time(
+ Some(channel),
+ local_time,
+ fallback_value,
+ ) {
+ value => value,
+ },
+ ChannelData::Composite(components) => {
+ let ParamValue::String(fallback_color) = fallback_value else {
+ return fallback_value.clone();
+ };
+ let (Some(r), Some(g), Some(b), Some(a)) = (
+ components.get("r"),
+ components.get("g"),
+ components.get("b"),
+ components.get("a"),
+ ) else {
+ return fallback_value.clone();
+ };
+ let Some(fallback_components) = parse_color_to_linear_rgba(fallback_color) else {
+ return fallback_value.clone();
+ };
+
+ let component_at = |channel: &scene::AnimationChannel, fallback: f64| {
+ match channel_value_at_time(
+ Some(channel),
+ local_time,
+ &ParamValue::Number(fallback),
+ ) {
+ ParamValue::Number(value) => value,
+ _ => fallback,
+ }
+ };
+ ParamValue::String(format_linear_rgba(&LinearRgba {
+ r: component_at(r, fallback_components.r),
+ g: component_at(g, fallback_components.g),
+ b: component_at(b, fallback_components.b),
+ a: component_at(a, fallback_components.a),
+ }))
+ }
+ }
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+ use scene::{AnimationChannel, ScalarAnimationKey, ScalarChannel, ScalarSegmentType, TangentMode};
+ use std::collections::BTreeMap;
+
+ fn at(time: i64) -> MediaTime {
+ MediaTime::from_ticks(time)
+ }
+
+ #[test]
+ fn local_time_clamps_to_element_range() {
+ assert_eq!(get_element_local_time(at(50), at(100), at(200)), at(0));
+ assert_eq!(get_element_local_time(at(150), at(100), at(200)), at(50));
+ assert_eq!(get_element_local_time(at(400), at(100), at(200)), at(200));
+ }
+
+ #[test]
+ fn resolves_scalar_path_value() {
+ let channel = AnimationChannel::Scalar(ScalarChannel {
+ keys: vec![
+ ScalarAnimationKey {
+ id: "a".to_string(),
+ time: at(0),
+ value: 0.0,
+ left_handle: None,
+ right_handle: None,
+ segment_to_next: ScalarSegmentType::Linear,
+ tangent_mode: TangentMode::Flat,
+ },
+ ScalarAnimationKey {
+ id: "b".to_string(),
+ time: at(100),
+ value: 1.0,
+ left_handle: None,
+ right_handle: None,
+ segment_to_next: ScalarSegmentType::Linear,
+ tangent_mode: TangentMode::Flat,
+ },
+ ],
+ extrapolation: None,
+ });
+ let mut animations = BTreeMap::new();
+ animations.insert("opacity".to_string(), ChannelData::Single(channel));
+ assert_eq!(
+ resolve_animation_path_value_at_time(
+ Some(&animations),
+ "opacity",
+ at(50),
+ &ParamValue::Number(1.0),
+ ),
+ ParamValue::Number(0.5)
+ );
+ assert_eq!(
+ resolve_animation_path_value_at_time(
+ Some(&animations),
+ "volume",
+ at(50),
+ &ParamValue::Number(1.0),
+ ),
+ ParamValue::Number(1.0)
+ );
+ }
+
+ #[test]
+ fn resolves_composite_color_path_value() {
+ let component = |value: f64| {
+ AnimationChannel::Scalar(ScalarChannel {
+ keys: vec![ScalarAnimationKey {
+ id: "k".to_string(),
+ time: at(0),
+ value,
+ left_handle: None,
+ right_handle: None,
+ segment_to_next: ScalarSegmentType::Linear,
+ tangent_mode: TangentMode::Flat,
+ }],
+ extrapolation: None,
+ })
+ };
+ let mut components = BTreeMap::new();
+ components.insert("r".to_string(), component(1.0));
+ components.insert("g".to_string(), component(0.0));
+ components.insert("b".to_string(), component(0.0));
+ components.insert("a".to_string(), component(1.0));
+ let mut animations = BTreeMap::new();
+ animations.insert("color".to_string(), ChannelData::Composite(components));
+ assert_eq!(
+ resolve_animation_path_value_at_time(
+ Some(&animations),
+ "color",
+ at(0),
+ &ParamValue::String("#ffffff".to_string()),
+ ),
+ ParamValue::String("#ff0000".to_string())
+ );
+ assert_eq!(
+ resolve_animation_path_value_at_time(
+ Some(&animations),
+ "color",
+ at(0),
+ &ParamValue::Number(0.0),
+ ),
+ ParamValue::Number(0.0)
+ );
+ }
+}
diff --git a/rust/crates/animation/src/values.rs b/rust/crates/animation/src/values.rs
new file mode 100644
index 00000000..d16bf16a
--- /dev/null
+++ b/rust/crates/animation/src/values.rs
@@ -0,0 +1,89 @@
+use scene::ElementAnimations;
+use time::MediaTime;
+
+use crate::resolve::resolve_animation_path_value_at_time;
+use scene::ParamValue;
+
+pub fn resolve_opacity_at_time(
+ base_opacity: f64,
+ animations: Option<&ElementAnimations>,
+ local_time: MediaTime,
+) -> f64 {
+ match resolve_animation_path_value_at_time(
+ animations,
+ "opacity",
+ local_time.max(MediaTime::ZERO),
+ &ParamValue::Number(base_opacity),
+ ) {
+ ParamValue::Number(value) => value,
+ _ => base_opacity,
+ }
+}
+
+pub fn resolve_number_at_time(
+ base_value: f64,
+ animations: Option<&ElementAnimations>,
+ property_path: &str,
+ local_time: MediaTime,
+) -> f64 {
+ match resolve_animation_path_value_at_time(
+ animations,
+ property_path,
+ local_time.max(MediaTime::ZERO),
+ &ParamValue::Number(base_value),
+ ) {
+ ParamValue::Number(value) => value,
+ _ => base_value,
+ }
+}
+
+pub fn resolve_color_at_time(
+ base_color: &str,
+ animations: Option<&ElementAnimations>,
+ property_path: &str,
+ local_time: MediaTime,
+) -> String {
+ match resolve_animation_path_value_at_time(
+ animations,
+ property_path,
+ local_time.max(MediaTime::ZERO),
+ &ParamValue::String(base_color.to_string()),
+ ) {
+ ParamValue::String(value) => value,
+ _ => base_color.to_string(),
+ }
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+ use scene::{AnimationChannel, ChannelData, ScalarAnimationKey, ScalarChannel, ScalarSegmentType, TangentMode};
+ use std::collections::BTreeMap;
+
+ #[test]
+ fn resolves_opacity_with_animation_and_fallback() {
+ let channel = AnimationChannel::Scalar(ScalarChannel {
+ keys: vec![ScalarAnimationKey {
+ id: "a".to_string(),
+ time: MediaTime::from_ticks(0),
+ value: 0.25,
+ left_handle: None,
+ right_handle: None,
+ segment_to_next: ScalarSegmentType::Linear,
+ tangent_mode: TangentMode::Flat,
+ }],
+ extrapolation: None,
+ });
+ let mut animations: ElementAnimations = BTreeMap::new();
+ animations.insert("opacity".to_string(), ChannelData::Single(channel));
+ assert_eq!(
+ resolve_opacity_at_time(1.0, Some(&animations), MediaTime::from_ticks(0)),
+ 0.25
+ );
+ assert_eq!(
+ resolve_opacity_at_time(1.0, Some(&animations), MediaTime::from_ticks(-5)),
+ 0.25
+ );
+ assert_eq!(resolve_opacity_at_time(1.0, None, MediaTime::from_ticks(0)), 1.0);
+ }
+}
diff --git a/rust/crates/audio/Cargo.toml b/rust/crates/audio/Cargo.toml
new file mode 100644
index 00000000..a8d897cc
--- /dev/null
+++ b/rust/crates/audio/Cargo.toml
@@ -0,0 +1,11 @@
+[package]
+name = "audio"
+version = "0.1.0"
+edition = "2024"
+
+[dependencies]
+animation = { version = "0.1.0", path = "../animation" }
+scene = { version = "0.1.0", path = "../scene" }
+time = { version = "0.1.0", path = "../time" }
+media = { version = "0.1.0", path = "../media" }
+thiserror = "2"
diff --git a/rust/crates/audio/src/buckets.rs b/rust/crates/audio/src/buckets.rs
new file mode 100644
index 00000000..261c2373
--- /dev/null
+++ b/rust/crates/audio/src/buckets.rs
@@ -0,0 +1,105 @@
+pub const RMS_ANALYSIS_WINDOW_SECONDS: f64 = 0.02;
+
+pub fn sample_bucket_range(
+ start_sample: usize,
+ end_sample: usize,
+ bucket_index: usize,
+ bucket_count: usize,
+) -> (usize, usize) {
+ let range_length = end_sample.saturating_sub(start_sample);
+ let bucket_start = start_sample + bucket_index * range_length / bucket_count;
+ let bucket_end = start_sample + (bucket_index + 1) * range_length / bucket_count;
+ (bucket_start, bucket_end.max(bucket_start))
+}
+
+pub fn extract_peak_range(
+ samples: &[f32],
+ channels: usize,
+ count: usize,
+ start_sample: usize,
+ end_sample: usize,
+) -> Vec<f32> {
+ let channels = channels.max(1);
+ let frames = samples.len() / channels;
+ let end = end_sample.min(frames);
+ let start = start_sample.min(end);
+ let mut peaks = vec![0.0_f32; count];
+ for (index, peak) in peaks.iter_mut().enumerate() {
+ let (bucket_start, bucket_end) = sample_bucket_range(start, end, index, count);
+ let mut bucket_peak = 0.0_f32;
+ for frame in bucket_start..bucket_end {
+ for channel in 0..channels {
+ let magnitude = samples[frame * channels + channel].abs();
+ if magnitude > bucket_peak {
+ bucket_peak = magnitude;
+ }
+ }
+ }
+ *peak = bucket_peak;
+ }
+ peaks
+}
+
+pub fn extract_rms_windowed(
+ samples: &[f32],
+ channels: usize,
+ sample_rate: u32,
+ buckets: &[(usize, usize)],
+) -> Vec<f32> {
+ let channels = channels.max(1);
+ let max_window =
+ (f64::from(sample_rate) * RMS_ANALYSIS_WINDOW_SECONDS).floor().max(1.0) as usize;
+ buckets
+ .iter()
+ .map(|&(bucket_start, bucket_end)| {
+ let bucket_length = bucket_end.saturating_sub(bucket_start);
+ if bucket_length == 0 {
+ return 0.0;
+ }
+ let window_length = bucket_length.min(max_window).max(1);
+ let mut max_mean_square = 0.0_f64;
+ let mut window_start = bucket_start;
+ while window_start < bucket_end {
+ let window_end = (window_start + window_length).min(bucket_end);
+ let n = window_end - window_start;
+ let mut sum = 0.0_f64;
+ for frame in window_start..window_end {
+ for channel in 0..channels {
+ let value = f64::from(samples[frame * channels + channel]);
+ sum += value * value;
+ }
+ }
+ let mean_square = sum / (n * channels) as f64;
+ if mean_square > max_mean_square {
+ max_mean_square = mean_square;
+ }
+ window_start = window_end;
+ }
+ max_mean_square.sqrt() as f32
+ })
+ .collect()
+}
+
+pub fn extract_rms_range(
+ samples: &[f32],
+ channels: usize,
+ sample_rate: u32,
+ count: usize,
+ start_sample: usize,
+ end_sample: usize,
+) -> Vec<f32> {
+ let buckets: Vec<(usize, usize)> = (0..count)
+ .map(|index| sample_bucket_range(start_sample, end_sample, index, count))
+ .collect();
+ extract_rms_windowed(samples, channels, sample_rate, &buckets)
+}
+
+pub fn extract_rms_buckets(
+ samples: &[f32],
+ channels: usize,
+ sample_rate: u32,
+ bucket_count: usize,
+) -> Vec<f32> {
+ let frames = samples.len() / channels.max(1);
+ extract_rms_range(samples, channels, sample_rate, bucket_count, 0, frames)
+}
diff --git a/rust/crates/audio/src/clips.rs b/rust/crates/audio/src/clips.rs
new file mode 100644
index 00000000..1f4c839c
--- /dev/null
+++ b/rust/crates/audio/src/clips.rs
@@ -0,0 +1,169 @@
+use std::path::PathBuf;
+
+use animation::{has_keyframes_for_path, resolve_number_at_time};
+use scene::{
+ AudioElement, BaseTimelineElement, Element, ElementAnimations, ParamValue, RetimeConfig,
+ SceneTracks, Track,
+};
+use time::MediaTime;
+
+pub const MIN_RETIME_RATE: f64 = 0.01;
+pub const MAX_RETIME_RATE: f64 = 5.0;
+pub const VOLUME_DB_MIN: f64 = -60.0;
+pub const VOLUME_DB_MAX: f64 = 20.0;
+
+pub fn clamp_retime_rate(rate: f64) -> f64 {
+ if !rate.is_finite() || rate <= 0.0 {
+ return 1.0;
+ }
+ rate.clamp(MIN_RETIME_RATE, MAX_RETIME_RATE)
+}
+
+pub fn source_time_at_clip_time(clip_time: f64, retime: Option<&RetimeConfig>) -> f64 {
+ clip_time * clamp_retime_rate(retime.map(|config| config.rate).unwrap_or(1.0))
+}
+
+pub fn clamp_db(value: f64) -> f64 {
+ if !value.is_finite() {
+ return 0.0;
+ }
+ value.clamp(VOLUME_DB_MIN, VOLUME_DB_MAX)
+}
+
+pub fn db_to_linear(db: f64) -> f64 {
+ 10_f64.powf(clamp_db(db) / 20.0)
+}
+
+#[derive(Debug, Clone)]
+pub struct AudibleClip {
+ pub element_id: String,
+ pub path: PathBuf,
+ pub start_time: MediaTime,
+ pub duration: MediaTime,
+ pub trim_start: MediaTime,
+ pub trim_end: MediaTime,
+ pub volume_db: f64,
+ pub retime: Option<RetimeConfig>,
+ pub animations: Option<ElementAnimations>,
+}
+
+impl AudibleClip {
+ pub fn end_time(&self) -> MediaTime {
+ self.start_time + self.duration
+ }
+
+ pub fn effective_rate(&self) -> f64 {
+ clamp_retime_rate(self.retime.map(|config| config.rate).unwrap_or(1.0))
+ }
+
+ pub fn requires_maintain_pitch(&self) -> bool {
+ self.retime.is_some_and(|config| {
+ config.maintain_pitch == Some(true)
+ && config.rate.is_finite()
+ && config.rate > 0.0
+ && clamp_retime_rate(config.rate) != 1.0
+ })
+ }
+
+ pub fn has_animated_volume(&self) -> bool {
+ has_keyframes_for_path(self.animations.as_ref(), "volume")
+ }
+
+ pub fn gain_at(&self, local_time: MediaTime) -> f64 {
+ db_to_linear(resolve_number_at_time(
+ self.volume_db,
+ self.animations.as_ref(),
+ "volume",
+ local_time,
+ ))
+ }
+
+ pub fn static_gain(&self) -> f64 {
+ db_to_linear(self.volume_db)
+ }
+}
+
+fn track_audio_muted(track: &Track) -> bool {
+ match track {
+ Track::Video { muted, .. } | Track::Audio { muted, .. } => *muted,
+ _ => false,
+ }
+}
+
+fn element_source(element: &Element) -> Option<(&BaseTimelineElement, &str, Option<RetimeConfig>)> {
+ match element {
+ Element::Audio(AudioElement::Upload(upload)) => {
+ Some((&upload.base, upload.media_id.as_str(), upload.retime))
+ }
+ Element::Audio(AudioElement::Library(library)) => {
+ Some((&library.base, library.source_url.as_str(), library.retime))
+ }
+ Element::Video(video) => {
+ if video.hidden == Some(true) || video.is_source_audio_enabled == Some(false) {
+ return None;
+ }
+ Some((&video.base, video.media_id.as_str(), video.retime))
+ }
+ _ => None,
+ }
+}
+
+fn collect_from_track(
+ track: &Track,
+ media: &dyn Fn(&str) -> Option<PathBuf>,
+ range: (MediaTime, MediaTime),
+ clips: &mut Vec<AudibleClip>,
+) {
+ if track_audio_muted(track) {
+ return;
+ }
+ for element in track.elements() {
+ let Some((base, source_key, retime)) = element_source(element) else {
+ continue;
+ };
+ if base.duration <= MediaTime::ZERO {
+ continue;
+ }
+ let end_time = base.start_time + base.duration;
+ if end_time <= range.0 || base.start_time >= range.1 {
+ continue;
+ }
+ if base.params.get("muted") == Some(&ParamValue::Bool(true)) {
+ continue;
+ }
+ let volume_db = match base.params.get("volume") {
+ Some(ParamValue::Number(value)) => *value,
+ _ => 0.0,
+ };
+ let Some(path) = media(source_key) else {
+ continue;
+ };
+ clips.push(AudibleClip {
+ element_id: base.id.clone(),
+ path,
+ start_time: base.start_time,
+ duration: base.duration,
+ trim_start: base.trim_start,
+ trim_end: base.trim_end,
+ volume_db,
+ retime,
+ animations: base.animations.clone(),
+ });
+ }
+}
+
+pub fn collect_audible_elements(
+ tracks: &SceneTracks,
+ media: &dyn Fn(&str) -> Option<PathBuf>,
+ range: (MediaTime, MediaTime),
+) -> Vec<AudibleClip> {
+ let mut clips = Vec::new();
+ for track in &tracks.overlay {
+ collect_from_track(track, media, range, &mut clips);
+ }
+ collect_from_track(&tracks.main, media, range, &mut clips);
+ for track in &tracks.audio {
+ collect_from_track(track, media, range, &mut clips);
+ }
+ clips
+}
diff --git a/rust/crates/audio/src/lib.rs b/rust/crates/audio/src/lib.rs
new file mode 100644
index 00000000..0bce5647
--- /dev/null
+++ b/rust/crates/audio/src/lib.rs
@@ -0,0 +1,29 @@
+mod buckets;
+mod clips;
+mod mix;
+
+pub use buckets::{
+ extract_peak_range, extract_rms_buckets, extract_rms_range, extract_rms_windowed,
+ sample_bucket_range,
+};
+pub use clips::{
+ AudibleClip, clamp_db, clamp_retime_rate, collect_audible_elements, db_to_linear,
+ source_time_at_clip_time,
+};
+pub use mix::{apply_mastering, mix_range, render_export_buffer};
+
+#[derive(Debug, thiserror::Error)]
+pub enum Error {
+ #[error(
+ "element {element_id} requires pitch-preserving retime at rate {rate}, which is not supported"
+ )]
+ MaintainPitchUnsupported { element_id: String, rate: f64 },
+ #[error("invalid time range: end must be after start")]
+ InvalidTimeRange,
+ #[error("sample rate must be at least 1")]
+ InvalidSampleRate,
+ #[error("channel count must be at least 1")]
+ InvalidChannelCount,
+}
+
+pub type Result<T> = std::result::Result<T, Error>;
diff --git a/rust/crates/audio/src/mix.rs b/rust/crates/audio/src/mix.rs
new file mode 100644
index 00000000..887111eb
--- /dev/null
+++ b/rust/crates/audio/src/mix.rs
@@ -0,0 +1,181 @@
+use media::{AudioSamples, Decoder};
+use time::MediaTime;
+
+use crate::Error;
+use crate::clips::AudibleClip;
+
+pub const MASTER_LIMITER_THRESHOLD_DB: f64 = -1.0;
+pub const MASTER_LIMITER_RATIO: f64 = 20.0;
+pub const MASTER_LIMITER_ATTACK_SECONDS: f64 = 0.001;
+pub const MASTER_LIMITER_RELEASE_SECONDS: f64 = 0.12;
+pub const MASTER_OUTPUT_HEADROOM: f64 = 0.98;
+pub const MASTER_OUTPUT_HEADROOM_F32: f32 = 0.98;
+
+fn decode_clip_source(clip: &AudibleClip, source_start: f64, source_end: f64) -> Option<AudioSamples> {
+ let start = MediaTime::from_seconds_f64(source_start.max(0.0))?;
+ let end = MediaTime::from_seconds_f64(source_end.max(0.0))?;
+ if end <= start {
+ return None;
+ }
+ let mut decoder = Decoder::open(&clip.path).ok()?;
+ let stream = decoder.info().audio_streams.first().cloned()?;
+ decoder.decode_audio_range(stream.index, start, end).ok()
+}
+
+fn mix_clip(
+ clip: &AudibleClip,
+ range_start_seconds: f64,
+ out_frames: usize,
+ sample_rate: u32,
+ channel_count: usize,
+ output: &mut [f32],
+) {
+ let clip_start_seconds = clip.start_time.to_seconds_f64();
+ let clip_end_seconds = clip_start_seconds + clip.duration.to_seconds_f64();
+ let rate = clip.effective_rate();
+ let trim_start_seconds = clip.trim_start.to_seconds_f64();
+ let rate_f64 = f64::from(sample_rate);
+
+ let first_frame = ((clip_start_seconds - range_start_seconds) * rate_f64)
+ .ceil()
+ .max(0.0) as usize;
+ let last_frame = (((clip_end_seconds - range_start_seconds) * rate_f64)
+ .ceil()
+ .max(0.0) as usize)
+ .min(out_frames);
+ if last_frame <= first_frame {
+ return;
+ }
+
+ let source_start = trim_start_seconds
+ + (first_frame as f64 / rate_f64 + range_start_seconds - clip_start_seconds) * rate;
+ let source_end = trim_start_seconds
+ + (last_frame as f64 / rate_f64 + range_start_seconds - clip_start_seconds) * rate;
+ let Some(decoded) = decode_clip_source(clip, source_start, source_end) else {
+ return;
+ };
+ let decoded_channels = decoded.channels as usize;
+ let decoded_frames = decoded.samples.len() / decoded_channels;
+ let decoded_rate = f64::from(decoded.sample_rate);
+ let static_gain = clip.static_gain() as f32;
+ let animated_volume = clip.has_animated_volume();
+
+ for frame in first_frame..last_frame {
+ let clip_time = frame as f64 / rate_f64 + range_start_seconds - clip_start_seconds;
+ let source_time = trim_start_seconds + clip_time * rate;
+ let source_position = (source_time - source_start) * decoded_rate;
+ if source_position >= decoded_frames as f64 {
+ break;
+ }
+ let source_position = source_position.max(0.0);
+ let lower = source_position.floor() as usize;
+ let upper = (lower + 1).min(decoded_frames.saturating_sub(1));
+ let fraction = (source_position - lower as f64) as f32;
+ let gain = if animated_volume {
+ clip.gain_at(MediaTime::from_seconds_f64(clip_time).unwrap_or(MediaTime::ZERO)) as f32
+ } else {
+ static_gain
+ };
+ for channel in 0..channel_count {
+ let source_channel = channel.min(decoded_channels - 1);
+ let a = decoded.samples[lower * decoded_channels + source_channel];
+ let b = decoded.samples[upper * decoded_channels + source_channel];
+ output[frame * channel_count + channel] +=
+ (a * (1.0 - fraction) + b * fraction) * gain;
+ }
+ }
+}
+
+pub fn mix_range(
+ clips: &[AudibleClip],
+ range: (MediaTime, MediaTime),
+ sample_rate: u32,
+ channels: u32,
+) -> Result<Vec<f32>, Error> {
+ if range.1 <= range.0 {
+ return Err(Error::InvalidTimeRange);
+ }
+ if sample_rate == 0 {
+ return Err(Error::InvalidSampleRate);
+ }
+ if channels == 0 {
+ return Err(Error::InvalidChannelCount);
+ }
+ for clip in clips {
+ if clip.requires_maintain_pitch()
+ && clip.end_time() > range.0
+ && clip.start_time < range.1
+ {
+ return Err(Error::MaintainPitchUnsupported {
+ element_id: clip.element_id.clone(),
+ rate: clip.effective_rate(),
+ });
+ }
+ }
+
+ let range_seconds = (range.1 - range.0).to_seconds_f64();
+ let out_frames = (range_seconds * f64::from(sample_rate)).ceil() as usize;
+ let channel_count = channels as usize;
+ let mut output = vec![0.0_f32; out_frames * channel_count];
+ let range_start_seconds = range.0.to_seconds_f64();
+ for clip in clips {
+ mix_clip(
+ clip,
+ range_start_seconds,
+ out_frames,
+ sample_rate,
+ channel_count,
+ &mut output,
+ );
+ }
+ Ok(output)
+}
+
+pub fn apply_mastering(samples: &mut [f32], channels: usize, sample_rate: u32) {
+ let peak = samples.iter().fold(0.0_f32, |peak, s| peak.max(s.abs()));
+ if f64::from(peak) <= MASTER_OUTPUT_HEADROOM {
+ return;
+ }
+ let attack_coeff =
+ (-1.0 / (MASTER_LIMITER_ATTACK_SECONDS * f64::from(sample_rate))).exp();
+ let release_coeff =
+ (-1.0 / (MASTER_LIMITER_RELEASE_SECONDS * f64::from(sample_rate))).exp();
+ let mut envelope_db = 0.0_f64;
+ for frame in samples.chunks_mut(channels) {
+ let level = frame.iter().fold(0.0_f32, |level, s| level.max(s.abs()));
+ let level_db = 20.0 * f64::from(level.max(1e-6)).log10();
+ let over = level_db - MASTER_LIMITER_THRESHOLD_DB;
+ let target_db = if over > 0.0 {
+ -over * (1.0 - 1.0 / MASTER_LIMITER_RATIO)
+ } else {
+ 0.0
+ };
+ let coeff = if target_db < envelope_db {
+ attack_coeff
+ } else {
+ release_coeff
+ };
+ envelope_db = target_db + (envelope_db - target_db) * coeff;
+ let gain = 10_f64.powf(envelope_db / 20.0) * MASTER_OUTPUT_HEADROOM;
+ for sample in frame.iter_mut() {
+ *sample = (f64::from(*sample) * gain) as f32;
+ }
+ }
+ for sample in samples.iter_mut() {
+ *sample = sample.clamp(-MASTER_OUTPUT_HEADROOM_F32, MASTER_OUTPUT_HEADROOM_F32);
+ }
+}
+
+pub fn render_export_buffer(
+ clips: &[AudibleClip],
+ total_duration: MediaTime,
+ sample_rate: u32,
+ channels: u32,
+) -> Result<Vec<f32>, Error> {
+ if clips.is_empty() || total_duration <= MediaTime::ZERO {
+ return Ok(Vec::new());
+ }
+ let mut buffer = mix_range(clips, (MediaTime::ZERO, total_duration), sample_rate, channels)?;
+ apply_mastering(&mut buffer, channels as usize, sample_rate);
+ Ok(buffer)
+}
diff --git a/rust/crates/audio/tests/collection.rs b/rust/crates/audio/tests/collection.rs
new file mode 100644
index 00000000..6c1f8a9f
--- /dev/null
+++ b/rust/crates/audio/tests/collection.rs
@@ -0,0 +1,263 @@
+use std::collections::BTreeMap;
+use std::path::PathBuf;
+
+use audio::collect_audible_elements;
+use scene::{
+ AudioElement, BaseTimelineElement, Element, LibraryAudioElement, ParamValue, RetimeConfig,
+ SceneTracks, Track, UploadAudioElement, VideoElement,
+};
+use time::MediaTime;
+
+fn seconds(value: f64) -> MediaTime {
+ MediaTime::from_seconds_f64(value).expect("finite seconds")
+}
+
+fn base(id: &str, start: f64, duration: f64) -> BaseTimelineElement {
+ BaseTimelineElement {
+ id: id.to_string(),
+ name: id.to_string(),
+ duration: seconds(duration),
+ start_time: seconds(start),
+ trim_start: MediaTime::ZERO,
+ trim_end: MediaTime::ZERO,
+ source_duration: None,
+ animations: None,
+ params: BTreeMap::new(),
+ }
+}
+
+fn upload_audio(id: &str, start: f64, duration: f64) -> Element {
+ Element::Audio(AudioElement::Upload(UploadAudioElement {
+ base: base(id, start, duration),
+ retime: None,
+ media_id: format!("media-{id}"),
+ }))
+}
+
+fn library_audio(id: &str, start: f64, duration: f64) -> Element {
+ Element::Audio(AudioElement::Library(LibraryAudioElement {
+ base: base(id, start, duration),
+ retime: None,
+ source_url: format!("https://cdn.test/{id}.mp3"),
+ }))
+}
+
+fn video(id: &str, start: f64, duration: f64) -> Element {
+ Element::Video(VideoElement {
+ base: base(id, start, duration),
+ media_id: format!("media-{id}"),
+ is_source_audio_enabled: None,
+ hidden: None,
+ retime: None,
+ effects: None,
+ masks: None,
+ })
+}
+
+fn main_track(elements: Vec<Element>) -> Track {
+ Track::Video {
+ id: "main".to_string(),
+ name: "main".to_string(),
+ elements,
+ muted: false,
+ hidden: false,
+ }
+}
+
+fn audio_track(id: &str, muted: bool, elements: Vec<Element>) -> Track {
+ Track::Audio {
+ id: id.to_string(),
+ name: id.to_string(),
+ elements,
+ muted,
+ }
+}
+
+fn tracks(main: Vec<Element>, audio: Vec<Track>) -> SceneTracks {
+ SceneTracks {
+ overlay: vec![],
+ main: main_track(main),
+ audio,
+ }
+}
+
+fn resolve_any(key: &str) -> Option<PathBuf> {
+ Some(PathBuf::from(key))
+}
+
+fn full_range() -> (MediaTime, MediaTime) {
+ (MediaTime::ZERO, seconds(60.0))
+}
+
+#[test]
+fn collects_upload_audio_and_video_with_source_audio() {
+ let scene = tracks(
+ vec![video("v1", 0.0, 2.0)],
+ vec![audio_track("a", false, vec![upload_audio("u1", 1.0, 1.0)])],
+ );
+ let clips = collect_audible_elements(&scene, &resolve_any, full_range());
+ assert_eq!(clips.len(), 2);
+ assert_eq!(clips[0].element_id, "v1");
+ assert_eq!(clips[1].element_id, "u1");
+ assert_eq!(clips[0].path, PathBuf::from("media-v1"));
+ assert_eq!(clips[1].start_time, seconds(1.0));
+ assert_eq!(clips[1].duration, seconds(1.0));
+}
+
+#[test]
+fn library_audio_resolves_by_source_url() {
+ let scene = tracks(
+ vec![],
+ vec![audio_track("a", false, vec![library_audio("lib", 0.0, 1.0)])],
+ );
+ let clips = collect_audible_elements(&scene, &resolve_any, full_range());
+ assert_eq!(clips.len(), 1);
+ assert_eq!(clips[0].path, PathBuf::from("https://cdn.test/lib.mp3"));
+}
+
+#[test]
+fn muted_track_excludes_its_elements() {
+ let scene = tracks(
+ vec![],
+ vec![
+ audio_track("muted-track", true, vec![upload_audio("u1", 0.0, 1.0)]),
+ audio_track("live-track", false, vec![upload_audio("u2", 0.0, 1.0)]),
+ ],
+ );
+ let clips = collect_audible_elements(&scene, &resolve_any, full_range());
+ assert_eq!(clips.len(), 1);
+ assert_eq!(clips[0].element_id, "u2");
+}
+
+#[test]
+fn muted_element_is_excluded() {
+ let mut element = match upload_audio("u1", 0.0, 1.0) {
+ Element::Audio(AudioElement::Upload(mut upload)) => {
+ upload
+ .base
+ .params
+ .insert("muted".to_string(), ParamValue::Bool(true));
+ Element::Audio(AudioElement::Upload(upload))
+ }
+ other => other,
+ };
+ let scene = tracks(vec![], vec![audio_track("a", false, vec![element])]);
+ let clips = collect_audible_elements(&scene, &resolve_any, full_range());
+ assert!(clips.is_empty());
+
+ element = match upload_audio("u2", 0.0, 1.0) {
+ Element::Audio(AudioElement::Upload(mut upload)) => {
+ upload
+ .base
+ .params
+ .insert("muted".to_string(), ParamValue::Bool(false));
+ Element::Audio(AudioElement::Upload(upload))
+ }
+ other => other,
+ };
+ let scene = tracks(vec![], vec![audio_track("a", false, vec![element])]);
+ assert_eq!(collect_audible_elements(&scene, &resolve_any, full_range()).len(), 1);
+}
+
+#[test]
+fn hidden_video_is_excluded() {
+ let hidden = Element::Video(VideoElement {
+ hidden: Some(true),
+ ..match video("v1", 0.0, 2.0) {
+ Element::Video(v) => v,
+ _ => unreachable!(),
+ }
+ });
+ let scene = tracks(vec![hidden], vec![]);
+ assert!(collect_audible_elements(&scene, &resolve_any, full_range()).is_empty());
+}
+
+#[test]
+fn video_with_source_audio_disabled_is_excluded() {
+ let disabled = Element::Video(VideoElement {
+ is_source_audio_enabled: Some(false),
+ ..match video("v1", 0.0, 2.0) {
+ Element::Video(v) => v,
+ _ => unreachable!(),
+ }
+ });
+ let enabled = Element::Video(VideoElement {
+ is_source_audio_enabled: Some(true),
+ ..match video("v2", 0.0, 2.0) {
+ Element::Video(v) => v,
+ _ => unreachable!(),
+ }
+ });
+ let scene = tracks(vec![disabled, enabled], vec![]);
+ let clips = collect_audible_elements(&scene, &resolve_any, full_range());
+ assert_eq!(clips.len(), 1);
+ assert_eq!(clips[0].element_id, "v2");
+}
+
+#[test]
+fn range_overlap_trims_collection() {
+ let scene = tracks(
+ vec![video("v1", 2.0, 2.0), video("v2", 10.0, 2.0)],
+ vec![],
+ );
+ let clips = collect_audible_elements(&scene, &resolve_any, (seconds(3.0), seconds(4.5)));
+ assert_eq!(clips.len(), 1);
+ assert_eq!(clips[0].element_id, "v1");
+
+ let clips = collect_audible_elements(&scene, &resolve_any, (seconds(4.0), seconds(10.0)));
+ assert!(clips.is_empty());
+
+ let clips = collect_audible_elements(&scene, &resolve_any, (seconds(4.0), seconds(10.5)));
+ assert_eq!(clips.len(), 1);
+ assert_eq!(clips[0].element_id, "v2");
+}
+
+#[test]
+fn volume_param_is_decibels_with_zero_default() {
+ let quiet = match upload_audio("u1", 0.0, 1.0) {
+ Element::Audio(AudioElement::Upload(mut upload)) => {
+ upload
+ .base
+ .params
+ .insert("volume".to_string(), ParamValue::Number(-6.0));
+ Element::Audio(AudioElement::Upload(upload))
+ }
+ other => other,
+ };
+ let plain = upload_audio("u2", 0.0, 1.0);
+ let scene = tracks(vec![], vec![audio_track("a", false, vec![quiet, plain])]);
+ let clips = collect_audible_elements(&scene, &resolve_any, full_range());
+ assert_eq!(clips[0].volume_db, -6.0);
+ assert_eq!(clips[1].volume_db, 0.0);
+ assert!((clips[0].static_gain() - 0.5012).abs() < 0.001);
+ assert_eq!(clips[1].static_gain(), 1.0);
+}
+
+#[test]
+fn retime_and_trim_are_carried() {
+ let element = match upload_audio("u1", 0.0, 0.5) {
+ Element::Audio(AudioElement::Upload(mut upload)) => {
+ upload.base.trim_start = seconds(0.25);
+ upload.base.trim_end = seconds(0.25);
+ upload.retime = Some(RetimeConfig {
+ rate: 2.0,
+ maintain_pitch: None,
+ });
+ Element::Audio(AudioElement::Upload(upload))
+ }
+ other => other,
+ };
+ let scene = tracks(vec![], vec![audio_track("a", false, vec![element])]);
+ let clips = collect_audible_elements(&scene, &resolve_any, full_range());
+ assert_eq!(clips.len(), 1);
+ assert_eq!(clips[0].trim_start, seconds(0.25));
+ assert_eq!(clips[0].effective_rate(), 2.0);
+ assert!(!clips[0].requires_maintain_pitch());
+}
+
+#[test]
+fn unresolvable_media_is_excluded() {
+ let scene = tracks(vec![video("v1", 0.0, 1.0)], vec![]);
+ let clips = collect_audible_elements(&scene, &|_| None, full_range());
+ assert!(clips.is_empty());
+}
diff --git a/rust/crates/audio/tests/fixtures/sample.mp4 b/rust/crates/audio/tests/fixtures/sample.mp4
new file mode 100644
index 00000000..13c113e5
--- /dev/null
+++ b/rust/crates/audio/tests/fixtures/sample.mp4
Binary files differ
diff --git a/rust/crates/audio/tests/fixtures/tone.wav b/rust/crates/audio/tests/fixtures/tone.wav
new file mode 100644
index 00000000..8d5f7886
--- /dev/null
+++ b/rust/crates/audio/tests/fixtures/tone.wav
Binary files differ
diff --git a/rust/crates/audio/tests/mix.rs b/rust/crates/audio/tests/mix.rs
new file mode 100644
index 00000000..6d0ed0d1
--- /dev/null
+++ b/rust/crates/audio/tests/mix.rs
@@ -0,0 +1,183 @@
+use std::path::PathBuf;
+
+use audio::{
+ AudibleClip, Error, apply_mastering, extract_rms_buckets, mix_range, render_export_buffer,
+};
+use media::Decoder;
+use scene::RetimeConfig;
+use time::MediaTime;
+
+const TONE_WAV: &str = concat!(env!("CARGO_MANIFEST_DIR"), "/tests/fixtures/tone.wav");
+
+fn seconds(value: f64) -> MediaTime {
+ MediaTime::from_seconds_f64(value).expect("finite seconds")
+}
+
+fn tone_clip(id: &str, start: f64, duration: f64) -> AudibleClip {
+ AudibleClip {
+ element_id: id.to_string(),
+ path: PathBuf::from(TONE_WAV),
+ start_time: seconds(start),
+ duration: seconds(duration),
+ trim_start: MediaTime::ZERO,
+ trim_end: MediaTime::ZERO,
+ volume_db: 0.0,
+ retime: None,
+ animations: None,
+ }
+}
+
+fn decode_tone(start: f64, end: f64) -> (Vec<f32>, u32, usize) {
+ let mut decoder = Decoder::open(TONE_WAV).expect("open tone.wav");
+ let stream = decoder.info().audio_streams.first().cloned().expect("audio stream");
+ let decoded = decoder
+ .decode_audio_range(stream.index, seconds(start), seconds(end))
+ .expect("decode tone");
+ (decoded.samples, decoded.sample_rate, decoded.channels as usize)
+}
+
+#[test]
+fn two_overlapping_tones_sum() {
+ let clip_a = tone_clip("a", 0.0, 1.0);
+ let clip_b = tone_clip("b", 0.0, 1.0);
+ let single = mix_range(&[clip_a.clone()], (seconds(0.0), seconds(1.0)), 44100, 2).unwrap();
+ let doubled = mix_range(&[clip_a, clip_b], (seconds(0.0), seconds(1.0)), 44100, 2).unwrap();
+ assert_eq!(single.len(), doubled.len());
+ for (one, two) in single.iter().zip(doubled.iter()) {
+ assert!((two - 2.0 * one).abs() < 1e-6, "{two} != 2 * {one}");
+ }
+}
+
+#[test]
+fn volume_param_scales_samples() {
+ let full = tone_clip("full", 0.0, 1.0);
+ let mut half = tone_clip("half", 0.0, 1.0);
+ half.volume_db = -6.020599913279624;
+ let full_mix = mix_range(&[full], (seconds(0.0), seconds(1.0)), 44100, 2).unwrap();
+ let half_mix = mix_range(&[half], (seconds(0.0), seconds(1.0)), 44100, 2).unwrap();
+ for (f, h) in full_mix.iter().zip(half_mix.iter()) {
+ assert!((h - f * 0.5).abs() < 1e-3, "{h} != 0.5 * {f}");
+ }
+}
+
+#[test]
+fn retime_2x_consumes_source_twice_as_fast() {
+ let normal = tone_clip("normal", 0.0, 1.0);
+ let mut fast = tone_clip("fast", 0.0, 0.5);
+ fast.retime = Some(RetimeConfig {
+ rate: 2.0,
+ maintain_pitch: None,
+ });
+ let normal_mix = mix_range(&[normal], (seconds(0.0), seconds(1.0)), 44100, 2).unwrap();
+ let fast_mix = mix_range(&[fast], (seconds(0.0), seconds(0.5)), 44100, 2).unwrap();
+ let frames = fast_mix.len() / 2;
+ for i in 0..frames {
+ for channel in 0..2 {
+ let expected = normal_mix[2 * i * 2 + channel];
+ let actual = fast_mix[i * 2 + channel];
+ assert!((actual - expected).abs() < 1e-6, "frame {i}: {actual} != {expected}");
+ }
+ }
+}
+
+#[test]
+fn trim_start_offsets_source() {
+ let mut clip = tone_clip("trimmed", 0.0, 0.5);
+ clip.trim_start = seconds(0.5);
+ let mix = mix_range(&[clip], (seconds(0.0), seconds(0.5)), 44100, 2).unwrap();
+ let (decoded, _rate, channels) = decode_tone(0.5, 1.0);
+ assert_eq!(channels, 1);
+ let frames = mix.len() / 2;
+ for i in 0..frames {
+ for channel in 0..2 {
+ let actual = mix[i * 2 + channel];
+ assert!((actual - decoded[i]).abs() < 1e-6, "frame {i}: {actual} != {}", decoded[i]);
+ }
+ }
+}
+
+#[test]
+fn rms_buckets_of_tone_match_hand_computed() {
+ let (samples, rate, channels) = decode_tone(0.0, 1.0);
+ let peak = samples.iter().fold(0.0_f32, |p, s| p.max(s.abs()));
+ let expected_rms = peak / 2.0_f32.sqrt();
+ let buckets = extract_rms_buckets(&samples, channels, rate, 10);
+ assert_eq!(buckets.len(), 10);
+ for bucket in &buckets {
+ let relative = (bucket - expected_rms).abs() / expected_rms;
+ assert!(relative < 0.05, "bucket {bucket} vs expected {expected_rms}");
+ }
+}
+
+#[test]
+fn mastering_limits_peaks_on_hot_input() {
+ let frames = 4096;
+ let mut samples = vec![1.5_f32; frames * 2];
+ apply_mastering(&mut samples, 2, 44100);
+ let peak = samples.iter().fold(0.0_f32, |p, s| p.max(s.abs()));
+ assert!(peak <= 0.98, "peak was {peak}");
+ assert!(peak > 0.9, "limiter over-attenuated: peak {peak}");
+}
+
+#[test]
+fn mastering_leaves_quiet_input_untouched() {
+ let mut samples: Vec<f32> = (0..1000).map(|i| (i as f32 / 1000.0) * 0.5).collect();
+ let original = samples.clone();
+ apply_mastering(&mut samples, 1, 44100);
+ assert_eq!(samples, original);
+}
+
+#[test]
+fn maintain_pitch_true_returns_typed_error() {
+ let mut clip = tone_clip("pitched", 0.0, 0.5);
+ clip.retime = Some(RetimeConfig {
+ rate: 2.0,
+ maintain_pitch: Some(true),
+ });
+ let result = mix_range(&[clip], (seconds(0.0), seconds(1.0)), 44100, 2);
+ match result {
+ Err(Error::MaintainPitchUnsupported { element_id, rate }) => {
+ assert_eq!(element_id, "pitched");
+ assert_eq!(rate, 2.0);
+ }
+ other => panic!("expected MaintainPitchUnsupported, got {other:?}"),
+ }
+}
+
+#[test]
+fn maintain_pitch_at_unity_rate_is_allowed() {
+ let mut clip = tone_clip("unity", 0.0, 0.5);
+ clip.retime = Some(RetimeConfig {
+ rate: 1.0,
+ maintain_pitch: Some(true),
+ });
+ assert!(mix_range(&[clip], (seconds(0.0), seconds(0.5)), 44100, 2).is_ok());
+}
+
+#[test]
+fn render_export_buffer_mixes_and_masters() {
+ let clip = tone_clip("a", 0.0, 1.0);
+ let buffer = render_export_buffer(&[clip], seconds(1.0), 44100, 2).unwrap();
+ assert_eq!(buffer.len(), 44100 * 2);
+ let peak = buffer.iter().fold(0.0_f32, |p, s| p.max(s.abs()));
+ assert!(peak > 0.05, "peak was {peak}");
+ assert!(render_export_buffer(&[], seconds(1.0), 44100, 2).unwrap().is_empty());
+}
+
+#[test]
+fn range_render_only_covers_requested_window() {
+ let clip = tone_clip("a", 1.0, 1.0);
+ let buffer = mix_range(&[clip], (seconds(0.0), seconds(2.0)), 44100, 2).unwrap();
+ let first_half = &buffer[..44100 * 2];
+ assert!(first_half.iter().all(|s| *s == 0.0));
+ let second_peak = buffer[44100 * 2..].iter().fold(0.0_f32, |p, s| p.max(s.abs()));
+ assert!(second_peak > 0.05);
+}
+
+#[test]
+fn undecodable_clip_contributes_silence() {
+ let mut clip = tone_clip("missing", 0.0, 1.0);
+ clip.path = PathBuf::from("/nonexistent/nope.wav");
+ let buffer = mix_range(&[clip], (seconds(0.0), seconds(0.5)), 44100, 2).unwrap();
+ assert!(buffer.iter().all(|s| *s == 0.0));
+}
diff --git a/rust/crates/commands/src/scene/create_scene.rs b/rust/crates/commands/src/scene/create_scene.rs
index 1f2de02b..b109e0ce 100644
--- a/rust/crates/commands/src/scene/create_scene.rs
+++ b/rust/crates/commands/src/scene/create_scene.rs
@@ -31,7 +31,13 @@ impl Command for CreateSceneCommand {
) -> Result<Option<CommandResult>, CommandError> {
self.saved_scenes = Some(project.scenes.clone());
- let created = timeline::scenes::build_default_scene(&self.name, self.is_main);
+ let created = timeline::scenes::build_default_scene(
+ uuid::Uuid::new_v4().to_string(),
+ uuid::Uuid::new_v4().to_string(),
+ self.name.clone(),
+ self.is_main,
+ chrono::Utc::now(),
+ );
self.created_scene_id = Some(created.id.clone());
project.scenes.push(created);
Ok(None)
diff --git a/rust/crates/commands/src/timeline/element/delete_elements.rs b/rust/crates/commands/src/timeline/element/delete_elements.rs
index bdcb5b6d..191ad4a8 100644
--- a/rust/crates/commands/src/timeline/element/delete_elements.rs
+++ b/rust/crates/commands/src/timeline/element/delete_elements.rs
@@ -4,13 +4,14 @@ use crate::command::{Command, CommandError, CommandResult};
use crate::project_ext::active_scene_mut;
fn remove_track_elements(track: &mut Track, elements: &[ElementRef]) {
- if !elements.iter().any(|target| target.track_id == track.id()) {
+ let track_id = track.id().to_string();
+ if !elements.iter().any(|target| target.track_id == track_id) {
return;
}
track.elements_mut().retain(|element| {
!elements
.iter()
- .any(|target| target.track_id == track.id() && target.element_id == element.base().id)
+ .any(|target| target.track_id == track_id && target.element_id == element.base().id)
});
}
diff --git a/rust/crates/commands/src/timeline/element/duplicate_elements.rs b/rust/crates/commands/src/timeline/element/duplicate_elements.rs
new file mode 100644
index 00000000..a77436ce
--- /dev/null
+++ b/rust/crates/commands/src/timeline/element/duplicate_elements.rs
@@ -0,0 +1,131 @@
+use scene::{Element, ElementRef, Project, SceneTracks};
+use timeline::TrackType;
+use timeline::placement::apply::ApplyPlacementParams;
+use timeline::placement::{
+ NewTrackPosition, PlacementResult, PlacementStrategy, PlacementSubject,
+ ResolveTrackPlacementParams, apply_placement, ordered_tracks, resolve_track_placement,
+};
+use uuid::Uuid;
+
+use crate::command::{Command, CommandError, CommandResult};
+use crate::project_ext::active_scene_mut;
+
+fn build_duplicate_element(element: &Element, id: String) -> Element {
+ let mut duplicate = element.clone();
+ {
+ let base = duplicate.base_mut();
+ base.id = id;
+ base.name = format!("{} (copy)", base.name);
+ base.animations = animation::clone_animations(base.animations.as_ref(), true);
+ }
+ duplicate
+}
+
+pub struct DuplicateElementsCommand {
+ elements: Vec<ElementRef>,
+ duplicated: Vec<ElementRef>,
+ saved_state: Option<SceneTracks>,
+}
+
+impl DuplicateElementsCommand {
+ pub fn new(elements: Vec<ElementRef>) -> Self {
+ Self {
+ elements,
+ duplicated: Vec::new(),
+ saved_state: None,
+ }
+ }
+
+ pub fn duplicated_elements(&self) -> &[ElementRef] {
+ &self.duplicated
+ }
+}
+
+impl Command for DuplicateElementsCommand {
+ fn execute(
+ &mut self,
+ project: &mut Project,
+ ) -> Result<Option<CommandResult>, CommandError> {
+ let Some(scene) = active_scene_mut(project) else {
+ return Ok(None);
+ };
+
+ self.saved_state = Some(scene.tracks.clone());
+ self.duplicated.clear();
+
+ let saved_state = scene.tracks.clone();
+ let mut updated_tracks = scene.tracks.clone();
+
+ for track in ordered_tracks(&saved_state) {
+ let new_elements: Vec<Element> = track
+ .elements()
+ .iter()
+ .filter(|element| {
+ self.elements.iter().any(|target| {
+ target.track_id == track.id() && target.element_id == element.base().id
+ })
+ })
+ .map(|element| build_duplicate_element(element, Uuid::new_v4().to_string()))
+ .collect();
+
+ if new_elements.is_empty() {
+ continue;
+ }
+
+ let placement_result = resolve_track_placement(&ResolveTrackPlacementParams {
+ tracks: updated_tracks.clone(),
+ subject: PlacementSubject::TrackType(TrackType::of_track(track)),
+ time_spans: Vec::new(),
+ strategy: PlacementStrategy::AlwaysNew {
+ position: NewTrackPosition::Highest,
+ },
+ });
+
+ let Some(placement_result @ PlacementResult::NewTrack { .. }) = placement_result
+ else {
+ continue;
+ };
+
+ let new_element_ids: Vec<String> = new_elements
+ .iter()
+ .map(|element| element.base().id.clone())
+ .collect();
+
+ let Some(applied) = apply_placement(&ApplyPlacementParams {
+ tracks: updated_tracks.clone(),
+ placement_result,
+ elements: new_elements,
+ new_track_id: Uuid::new_v4().to_string(),
+ new_track_insert_index_override: None,
+ }) else {
+ continue;
+ };
+
+ updated_tracks = applied.updated_tracks;
+ self.duplicated.extend(new_element_ids.into_iter().map(|element_id| {
+ ElementRef {
+ track_id: applied.target_track_id.clone(),
+ element_id,
+ }
+ }));
+ }
+
+ scene.tracks = updated_tracks;
+
+ if self.duplicated.is_empty() {
+ return Ok(None);
+ }
+ Ok(Some(CommandResult::element_selection(
+ self.duplicated.clone(),
+ )))
+ }
+
+ fn undo(&mut self, project: &mut Project) {
+ let Some(saved) = &self.saved_state else {
+ return;
+ };
+ if let Some(scene) = active_scene_mut(project) {
+ scene.tracks = saved.clone();
+ }
+ }
+}
diff --git a/rust/crates/commands/src/timeline/element/insert_element.rs b/rust/crates/commands/src/timeline/element/insert_element.rs
new file mode 100644
index 00000000..a2584c53
--- /dev/null
+++ b/rust/crates/commands/src/timeline/element/insert_element.rs
@@ -0,0 +1,195 @@
+use scene::{AudioElement, Element, ElementRef, ParamValue, Project, SceneTracks};
+use timeline::placement::apply::ApplyPlacementParams;
+use timeline::placement::{
+ PlacementResult, PlacementStrategy, PlacementSubject, PlacementTimeSpan,
+ ResolveTrackPlacementParams, apply_placement, can_element_go_on_track,
+ resolve_track_placement,
+};
+use timeline::{ElementType, TrackType};
+use uuid::Uuid;
+
+use crate::command::{Command, CommandError, CommandResult};
+use crate::project_ext::active_scene_mut;
+
+#[derive(Clone, Debug, PartialEq)]
+pub enum InsertElementPlacement {
+ Explicit {
+ track_id: String,
+ },
+ Auto {
+ track_type: Option<TrackType>,
+ insert_index: Option<usize>,
+ },
+}
+
+fn requires_media_id(element: &Element) -> bool {
+ matches!(
+ element,
+ Element::Video(_) | Element::Image(_) | Element::Audio(AudioElement::Upload(_))
+ )
+}
+
+fn has_media_id_value(element: &Element) -> bool {
+ match element {
+ Element::Video(video) => !video.media_id.is_empty(),
+ Element::Image(image) => !image.media_id.is_empty(),
+ Element::Audio(AudioElement::Upload(upload)) => !upload.media_id.is_empty(),
+ _ => false,
+ }
+}
+
+fn validate_element_basics(element: &Element) -> bool {
+ if requires_media_id(element) && !has_media_id_value(element) {
+ return false;
+ }
+
+ match element {
+ Element::Audio(AudioElement::Library(library)) => !library.source_url.is_empty(),
+ Element::Sticker(sticker) => !sticker.sticker_id.is_empty(),
+ Element::Graphic(graphic) => !graphic.definition_id.is_empty(),
+ Element::Text(text) => matches!(
+ text.base.params.get("content"),
+ Some(ParamValue::String(content)) if !content.is_empty()
+ ),
+ Element::Effect(effect) => !effect.effect_type.is_empty(),
+ _ => true,
+ }
+}
+
+pub struct InsertElementCommand {
+ element: Element,
+ placement: InsertElementPlacement,
+ element_id: String,
+ target_track_id: Option<String>,
+ saved_state: Option<SceneTracks>,
+}
+
+impl InsertElementCommand {
+ pub fn new(element: Element, placement: InsertElementPlacement) -> Self {
+ Self {
+ element,
+ placement,
+ element_id: Uuid::new_v4().to_string(),
+ target_track_id: None,
+ saved_state: None,
+ }
+ }
+
+ pub fn element_id(&self) -> &str {
+ &self.element_id
+ }
+
+ pub fn track_id(&self) -> Option<&str> {
+ self.target_track_id.as_deref()
+ }
+
+ fn apply_placement_result(
+ &self,
+ tracks: &SceneTracks,
+ element: &Element,
+ ) -> Option<(SceneTracks, String)> {
+ let element_type = ElementType::of_element(element);
+
+ if let InsertElementPlacement::Auto {
+ track_type: Some(track_type),
+ ..
+ } = &self.placement
+ && !can_element_go_on_track(element_type, *track_type)
+ {
+ return None;
+ }
+
+ let subject = match &self.placement {
+ InsertElementPlacement::Auto {
+ track_type: Some(track_type),
+ ..
+ } => PlacementSubject::TrackType(*track_type),
+ _ => PlacementSubject::ElementType(element_type),
+ };
+
+ let strategy = match &self.placement {
+ InsertElementPlacement::Explicit { track_id } => PlacementStrategy::Explicit {
+ track_id: track_id.clone(),
+ },
+ InsertElementPlacement::Auto { .. } => PlacementStrategy::FirstAvailable,
+ };
+
+ let placement_result = resolve_track_placement(&ResolveTrackPlacementParams {
+ tracks: tracks.clone(),
+ subject,
+ time_spans: vec![PlacementTimeSpan {
+ start_time: element.base().start_time,
+ duration: element.base().duration,
+ exclude_element_id: None,
+ }],
+ strategy,
+ })?;
+
+ let mut element_to_place = element.clone();
+ if let PlacementResult::ExistingTrack {
+ adjusted_start_time: Some(adjusted),
+ ..
+ } = &placement_result
+ {
+ element_to_place.base_mut().start_time = *adjusted;
+ }
+
+ let insert_index_override = match &self.placement {
+ InsertElementPlacement::Auto { insert_index, .. } => *insert_index,
+ InsertElementPlacement::Explicit { .. } => None,
+ };
+
+ let applied = apply_placement(&ApplyPlacementParams {
+ tracks: tracks.clone(),
+ placement_result,
+ elements: vec![element_to_place],
+ new_track_id: Uuid::new_v4().to_string(),
+ new_track_insert_index_override: insert_index_override,
+ })?;
+
+ Some((applied.updated_tracks, applied.target_track_id))
+ }
+}
+
+impl Command for InsertElementCommand {
+ fn execute(
+ &mut self,
+ project: &mut Project,
+ ) -> Result<Option<CommandResult>, CommandError> {
+ let Some(scene) = active_scene_mut(project) else {
+ return Ok(None);
+ };
+
+ self.saved_state = Some(scene.tracks.clone());
+
+ if !validate_element_basics(&self.element) {
+ return Ok(None);
+ }
+
+ let mut new_element = self.element.clone();
+ new_element.base_mut().id = self.element_id.clone();
+
+ let Some((updated_tracks, target_track_id)) =
+ self.apply_placement_result(&scene.tracks, &new_element)
+ else {
+ return Ok(None);
+ };
+
+ self.target_track_id = Some(target_track_id.clone());
+ scene.tracks = updated_tracks;
+
+ Ok(Some(CommandResult::element_selection(vec![ElementRef {
+ track_id: target_track_id,
+ element_id: self.element_id.clone(),
+ }])))
+ }
+
+ fn undo(&mut self, project: &mut Project) {
+ let Some(saved) = &self.saved_state else {
+ return;
+ };
+ if let Some(scene) = active_scene_mut(project) {
+ scene.tracks = saved.clone();
+ }
+ }
+}
diff --git a/rust/crates/commands/src/timeline/element/mod.rs b/rust/crates/commands/src/timeline/element/mod.rs
index 2ba806e1..a1d8de6e 100644
--- a/rust/crates/commands/src/timeline/element/mod.rs
+++ b/rust/crates/commands/src/timeline/element/mod.rs
@@ -1,3 +1,15 @@
pub mod delete_elements;
+pub mod duplicate_elements;
+pub mod insert_element;
+pub mod move_elements;
+pub mod shift_split_remainder;
+pub mod split_elements;
+pub mod update_elements;
pub use delete_elements::DeleteElementsCommand;
+pub use duplicate_elements::DuplicateElementsCommand;
+pub use insert_element::{InsertElementCommand, InsertElementPlacement};
+pub use move_elements::{MoveElementCommand, PlannedElementMove, PlannedTrackCreation};
+pub use shift_split_remainder::ShiftSplitRemainderCommand;
+pub use split_elements::{RetainSide, SplitElementsCommand};
+pub use update_elements::{ElementUpdate, UpdateElementsCommand};
diff --git a/rust/crates/commands/src/timeline/element/move_elements.rs b/rust/crates/commands/src/timeline/element/move_elements.rs
index fc6425d1..b9324ab8 100644
--- a/rust/crates/commands/src/timeline/element/move_elements.rs
+++ b/rust/crates/commands/src/timeline/element/move_elements.rs
@@ -40,8 +40,16 @@ fn insert_track_at_display_index(
}
fn validate_compatibility(element: &Element, track: &Track) -> Result<(), CommandError> {
- timeline::placement::validate_element_track_compatibility(element, track)
- .map_err(CommandError::IncompatiblePlacement)
+ let compatibility = timeline::placement::validate_element_track_compatibility(
+ timeline::ElementType::of_element(element),
+ timeline::TrackType::of_track(track),
+ );
+ if compatibility.is_valid {
+ return Ok(());
+ }
+ Err(CommandError::IncompatiblePlacement(
+ compatibility.error_message.unwrap_or_default(),
+ ))
}
pub struct MoveElementCommand {
@@ -75,7 +83,7 @@ impl Command for MoveElementCommand {
let mut create_tracks = self.create_tracks.clone();
create_tracks.sort_by_key(|creation| creation.index);
for creation in &create_tracks {
- let track = build_empty_track(&creation.id, creation.track_type, None);
+ let track = build_empty_track(creation.id.clone(), creation.track_type, None);
insert_track_at_display_index(&mut scene.tracks, track, creation.index);
}
diff --git a/rust/crates/commands/src/timeline/element/split_elements.rs b/rust/crates/commands/src/timeline/element/split_elements.rs
index 0da90792..98d292ab 100644
--- a/rust/crates/commands/src/timeline/element/split_elements.rs
+++ b/rust/crates/commands/src/timeline/element/split_elements.rs
@@ -1,50 +1,12 @@
use std::cell::RefCell;
use std::rc::Rc;
-use scene::{AudioElement, Element, ElementRef, Project, RetimeConfig, SceneTracks};
+use scene::{ElementRef, Project, SceneTracks};
use time::MediaTime;
-use uuid::Uuid;
+pub use timeline::split::RetainSide;
use crate::command::{Command, CommandError, CommandResult};
use crate::project_ext::active_scene_mut;
-use crate::tracks::all_tracks_mut;
-
-const MIN_RETIME_RATE: f64 = 0.01;
-const MAX_RETIME_RATE: f64 = 5.0;
-const DEFAULT_RETIME_RATE: f64 = 1.0;
-
-#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)]
-pub enum RetainSide {
- #[default]
- Both,
- Left,
- Right,
-}
-
-fn clamp_retime_rate(rate: f64) -> f64 {
- if !rate.is_finite() || rate <= 0.0 {
- return DEFAULT_RETIME_RATE;
- }
- rate.clamp(MIN_RETIME_RATE, MAX_RETIME_RATE)
-}
-
-fn source_span_at_clip_time(clip_time: MediaTime, retime: Option<RetimeConfig>) -> f64 {
- let rate = clamp_retime_rate(retime.map(|config| config.rate).unwrap_or(1.0));
- (clip_time.as_ticks() as f64 * rate).max(0.0)
-}
-
-fn round_to_ticks(time: f64) -> MediaTime {
- MediaTime::from_ticks(time.round() as i64)
-}
-
-fn retime_of(element: &Element) -> Option<RetimeConfig> {
- match element {
- Element::Video(video) => video.retime,
- Element::Audio(AudioElement::Upload(upload)) => upload.retime,
- Element::Audio(AudioElement::Library(library)) => library.retime,
- _ => None,
- }
-}
pub struct SplitElementsCommand {
elements: Vec<ElementRef>,
@@ -72,100 +34,6 @@ impl SplitElementsCommand {
pub fn right_side_elements(&self) -> Vec<ElementRef> {
self.right_side.borrow().clone()
}
-
- fn split_element(
- &self,
- track_id: &str,
- element: Element,
- right_side: &mut Vec<ElementRef>,
- ) -> Vec<Element> {
- let start = element.base().start_time;
- let end = start + element.base().duration;
-
- if self.split_time <= start || self.split_time >= end {
- return vec![element];
- }
-
- let relative_time = self.split_time - start;
- let left_visible_duration = relative_time;
- let right_visible_duration = element.base().duration - relative_time;
- let retime = retime_of(&element);
-
- let left_source_span = round_to_ticks(source_span_at_clip_time(
- left_visible_duration,
- retime,
- ));
- let total_source_span =
- round_to_ticks(source_span_at_clip_time(element.base().duration, retime));
- let right_source_span = total_source_span - left_source_span;
-
- let (left_animations, right_animations) = animation::split_animations_at_time_with_options(
- element.base().animations.as_ref(),
- relative_time,
- true,
- );
-
- let left_trim_end = element.base().trim_end + right_source_span;
- let right_trim_start = element.base().trim_start + left_source_span;
-
- match self.retain_side {
- RetainSide::Left => {
- let mut left = element;
- {
- let base = left.base_mut();
- base.duration = left_visible_duration;
- base.trim_end = left_trim_end;
- base.name = format!("{} (left)", base.name);
- base.animations = left_animations;
- }
- vec![left]
- }
- RetainSide::Right => {
- let mut right = element;
- let new_id = Uuid::new_v4().to_string();
- right_side.push(ElementRef {
- track_id: track_id.to_string(),
- element_id: new_id.clone(),
- });
- {
- let base = right.base_mut();
- base.id = new_id;
- base.start_time = self.split_time;
- base.duration = right_visible_duration;
- base.trim_start = right_trim_start;
- base.name = format!("{} (right)", base.name);
- base.animations = right_animations;
- }
- vec![right]
- }
- RetainSide::Both => {
- let mut left = element;
- let mut right = left.clone();
- let right_id = Uuid::new_v4().to_string();
- right_side.push(ElementRef {
- track_id: track_id.to_string(),
- element_id: right_id.clone(),
- });
- {
- let base = left.base_mut();
- base.duration = left_visible_duration;
- base.trim_end = left_trim_end;
- base.name = format!("{} (left)", base.name);
- base.animations = left_animations;
- }
- {
- let base = right.base_mut();
- base.id = right_id;
- base.start_time = self.split_time;
- base.duration = right_visible_duration;
- base.trim_start = right_trim_start;
- base.name = format!("{} (right)", base.name);
- base.animations = right_animations;
- }
- vec![left, right]
- }
- }
- }
}
impl Command for SplitElementsCommand {
@@ -178,34 +46,16 @@ impl Command for SplitElementsCommand {
};
self.saved_state = Some(scene.tracks.clone());
- self.right_side.borrow_mut().clear();
- for track in all_tracks_mut(&mut scene.tracks) {
- let targets: Vec<&str> = self
- .elements
- .iter()
- .filter(|target| target.track_id == track.id())
- .map(|target| target.element_id.as_str())
- .collect();
- if targets.is_empty() {
- continue;
- }
+ let result = timeline::split::split_elements(
+ &scene.tracks,
+ &self.elements,
+ self.split_time,
+ self.retain_side,
+ );
+ scene.tracks = result.tracks;
- let track_id = track.id().to_string();
- let previous = std::mem::take(track.elements_mut());
- let mut next = Vec::with_capacity(previous.len());
- {
- let mut right_side = self.right_side.borrow_mut();
- for element in previous {
- if !targets.contains(&element.base().id.as_str()) {
- next.push(element);
- continue;
- }
- next.extend(self.split_element(&track_id, element, &mut right_side));
- }
- }
- *track.elements_mut() = next;
- }
+ *self.right_side.borrow_mut() = result.right_side_elements;
let right_side = self.right_side.borrow();
if right_side.is_empty() {
diff --git a/rust/crates/commands/src/timeline/element/update_elements.rs b/rust/crates/commands/src/timeline/element/update_elements.rs
index f5931bb7..0ab21174 100644
--- a/rust/crates/commands/src/timeline/element/update_elements.rs
+++ b/rust/crates/commands/src/timeline/element/update_elements.rs
@@ -1,11 +1,11 @@
-use scene::{ElementRef, Project, SceneTracks};
+use scene::{Project, SceneTracks};
use timeline::update_pipeline::{ElementPatch, ElementUpdateContext, apply_element_update};
use crate::command::{Command, CommandError, CommandResult};
use crate::project_ext::active_scene_mut;
use crate::tracks::find_track_mut;
-#[derive(Clone, Debug, PartialEq)]
+#[derive(Clone, Debug)]
pub struct ElementUpdate {
pub track_id: String,
pub element_id: String,
@@ -54,8 +54,7 @@ impl Command for UpdateElementsCommand {
continue;
};
- let current = element.clone();
- *element = apply_element_update(current, update.patch.clone(), &context);
+ *element = apply_element_update(element, &update.patch, &context);
}
Ok(None)
diff --git a/rust/crates/commands/src/timeline/mod.rs b/rust/crates/commands/src/timeline/mod.rs
index 532885ce..b83d50b4 100644
--- a/rust/crates/commands/src/timeline/mod.rs
+++ b/rust/crates/commands/src/timeline/mod.rs
@@ -2,4 +2,6 @@ pub mod element;
pub mod track;
pub mod tracks_snapshot;
+pub use element::*;
+pub use track::*;
pub use tracks_snapshot::TracksSnapshotCommand;
diff --git a/rust/crates/commands/src/timeline/track/add_track.rs b/rust/crates/commands/src/timeline/track/add_track.rs
index d6e9bbae..4bf170c7 100644
--- a/rust/crates/commands/src/timeline/track/add_track.rs
+++ b/rust/crates/commands/src/timeline/track/add_track.rs
@@ -43,7 +43,7 @@ impl Command for AddTrackCommand {
get_default_insert_index_for_track(&scene.tracks, self.track_type)
});
- let new_track = build_empty_track(&self.track_id, self.track_type, None);
+ let new_track = build_empty_track(self.track_id.clone(), self.track_type, None);
if self.track_type == TrackType::Audio {
let audio_insert_index = insert_index
.saturating_sub(scene.tracks.overlay.len() + 1);
diff --git a/rust/crates/commands/tests/common/mod.rs b/rust/crates/commands/tests/common/mod.rs
new file mode 100644
index 00000000..4cf45a9a
--- /dev/null
+++ b/rust/crates/commands/tests/common/mod.rs
@@ -0,0 +1,118 @@
+#![allow(dead_code)]
+
+use scene::{
+ Background, BaseTimelineElement, CanvasSize, Element, ImageElement, ParamValue,
+ ParamValues, Project, ProjectMetadata, ProjectSettings, Scene, VideoElement,
+};
+use time::{FrameRate, MediaTime};
+
+pub const SPLIT_SECONDS: f64 = 4.0;
+pub const FREEZE_SECONDS: f64 = 3.0;
+pub const VIDEO_SECONDS: f64 = 10.0;
+
+pub fn seconds(value: f64) -> MediaTime {
+ MediaTime::from_seconds_f64(value).expect("finite seconds")
+}
+
+pub fn default_params() -> ParamValues {
+ ParamValues::from([
+ ("transform.positionX".to_string(), ParamValue::Number(0.0)),
+ ("transform.positionY".to_string(), ParamValue::Number(0.0)),
+ ("transform.scaleX".to_string(), ParamValue::Number(1.0)),
+ ("transform.scaleY".to_string(), ParamValue::Number(1.0)),
+ ("transform.rotate".to_string(), ParamValue::Number(0.0)),
+ ("opacity".to_string(), ParamValue::Number(1.0)),
+ ])
+}
+
+pub fn base_element(id: &str, name: &str, duration: MediaTime) -> BaseTimelineElement {
+ BaseTimelineElement {
+ id: id.to_string(),
+ name: name.to_string(),
+ duration,
+ start_time: MediaTime::ZERO,
+ trim_start: MediaTime::ZERO,
+ trim_end: MediaTime::ZERO,
+ source_duration: None,
+ animations: None,
+ params: default_params(),
+ }
+}
+
+pub fn build_video_element() -> Element {
+ Element::Video(VideoElement {
+ base: base_element("video-1", "video-1", seconds(VIDEO_SECONDS)),
+ media_id: "media-video-1".to_string(),
+ is_source_audio_enabled: None,
+ hidden: None,
+ retime: None,
+ effects: None,
+ masks: None,
+ })
+}
+
+pub fn build_freeze_image_element() -> Element {
+ let mut base = base_element("freeze-template", "Freeze frame", seconds(FREEZE_SECONDS));
+ base.start_time = seconds(SPLIT_SECONDS);
+ Element::Image(ImageElement {
+ base,
+ media_id: "media-freeze-1".to_string(),
+ hidden: None,
+ effects: None,
+ masks: None,
+ })
+}
+
+pub fn active_scene(project: &Project) -> &Scene {
+ project
+ .scenes
+ .iter()
+ .find(|scene| scene.id == project.current_scene_id)
+ .expect("active scene")
+}
+
+pub fn seed_project_with_video() -> (Project, String) {
+ let now = chrono::DateTime::parse_from_rfc3339("2026-01-01T00:00:00Z")
+ .expect("valid date")
+ .to_utc();
+
+ let mut scene: Scene = timeline::scenes::build_default_scene(
+ "scene-1".to_string(),
+ "main-track-1".to_string(),
+ "Main scene".to_string(),
+ true,
+ now,
+ );
+ scene.tracks.main.elements_mut().push(build_video_element());
+ let track_id = scene.tracks.main.id().to_string();
+
+ let project = Project {
+ metadata: ProjectMetadata {
+ id: "project-1".to_string(),
+ name: "Test project".to_string(),
+ thumbnail: None,
+ duration: seconds(VIDEO_SECONDS),
+ created_at: now,
+ updated_at: now,
+ },
+ scenes: vec![scene],
+ current_scene_id: "scene-1".to_string(),
+ settings: ProjectSettings {
+ fps: FrameRate::new(30, 1),
+ canvas_size: CanvasSize {
+ width: 1920,
+ height: 1080,
+ },
+ canvas_size_mode: None,
+ last_custom_canvas_size: None,
+ original_canvas_size: None,
+ background: Background::Color {
+ color: "#000000".to_string(),
+ },
+ },
+ version: 1,
+ timeline_view_state: None,
+ };
+
+ (project, track_id)
+}
diff --git a/rust/crates/commands/tests/framework.rs b/rust/crates/commands/tests/framework.rs
new file mode 100644
index 00000000..e3defbad
--- /dev/null
+++ b/rust/crates/commands/tests/framework.rs
@@ -0,0 +1,162 @@
+mod common;
+
+use std::cell::RefCell;
+use std::rc::Rc;
+
+use commands::{
+ BatchCommand, Command, CommandError, CommandHistory, CommandResult, DeleteElementsCommand,
+ PreviewTracker,
+};
+use common::{VIDEO_SECONDS, seconds, seed_project_with_video};
+use scene::{ElementRef, Project};
+
+struct RecordingCommand {
+ label: &'static str,
+ log: Rc<RefCell<Vec<&'static str>>>,
+ selection: Option<Vec<ElementRef>>,
+}
+
+impl RecordingCommand {
+ fn new(label: &'static str, log: &Rc<RefCell<Vec<&'static str>>>) -> Self {
+ Self {
+ label,
+ log: Rc::clone(log),
+ selection: None,
+ }
+ }
+
+ fn with_selection(mut self, selection: Vec<ElementRef>) -> Self {
+ self.selection = Some(selection);
+ self
+ }
+}
+
+impl Command for RecordingCommand {
+ fn execute(&mut self, _project: &mut Project) -> Result<Option<CommandResult>, CommandError> {
+ self.log.borrow_mut().push(self.label);
+ Ok(self
+ .selection
+ .clone()
+ .map(CommandResult::element_selection))
+ }
+
+ fn undo(&mut self, _project: &mut Project) {
+ self.log.borrow_mut().push(self.label);
+ }
+}
+
+#[test]
+fn batch_executes_in_order_and_undoes_in_reverse() {
+ let (mut project, _) = seed_project_with_video();
+ let log = Rc::new(RefCell::new(Vec::new()));
+
+ let mut batch = BatchCommand::new(vec![
+ Box::new(RecordingCommand::new("first", &log)),
+ Box::new(RecordingCommand::new("second", &log)),
+ Box::new(RecordingCommand::new("third", &log)),
+ ]);
+
+ batch.execute(&mut project).expect("execute");
+ assert_eq!(*log.borrow(), vec!["first", "second", "third"]);
+
+ log.borrow_mut().clear();
+ batch.undo(&mut project);
+ assert_eq!(*log.borrow(), vec!["third", "second", "first"]);
+}
+
+#[test]
+fn batch_returns_latest_selection_result() {
+ let (mut project, _) = seed_project_with_video();
+ let log = Rc::new(RefCell::new(Vec::new()));
+
+ let selected = vec![ElementRef {
+ track_id: "main-track-1".to_string(),
+ element_id: "video-1".to_string(),
+ }];
+ let mut batch = BatchCommand::new(vec![
+ Box::new(
+ RecordingCommand::new("selecting", &log).with_selection(selected.clone()),
+ ),
+ Box::new(RecordingCommand::new("plain", &log)),
+ ]);
+
+ let result = batch.execute(&mut project).expect("execute");
+ let selection = result
+ .and_then(|result| result.selection)
+ .expect("selection result");
+ assert_eq!(selection.selected_elements, selected);
+}
+
+#[test]
+fn history_undo_redo_roundtrip() {
+ let (mut project, track_id) = seed_project_with_video();
+ let mut history = CommandHistory::new();
+
+ assert!(!history.can_undo());
+ assert!(!history.can_redo());
+
+ history
+ .execute(
+ Box::new(DeleteElementsCommand::new(vec![ElementRef {
+ track_id: track_id.clone(),
+ element_id: "video-1".to_string(),
+ }])),
+ &mut project,
+ )
+ .expect("execute");
+
+ let scene = common::active_scene;
+ assert!(scene(&project).tracks.main.elements().is_empty());
+ assert!(history.can_undo());
+ assert!(!history.can_redo());
+
+ history.undo(&mut project);
+ assert_eq!(scene(&project).tracks.main.elements().len(), 1);
+ assert!(!history.can_undo());
+ assert!(history.can_redo());
+
+ history.redo(&mut project).expect("redo");
+ assert!(scene(&project).tracks.main.elements().is_empty());
+ assert!(history.can_undo());
+ assert!(!history.can_redo());
+}
+
+#[test]
+fn history_new_execute_clears_redo_stack() {
+ let (mut project, track_id) = seed_project_with_video();
+ let mut history = CommandHistory::new();
+
+ let delete = || {
+ Box::new(DeleteElementsCommand::new(vec![ElementRef {
+ track_id: track_id.clone(),
+ element_id: "video-1".to_string(),
+ }])) as Box<dyn Command>
+ };
+
+ history.execute(delete(), &mut project).expect("execute");
+ history.undo(&mut project);
+ assert!(history.can_redo());
+
+ history.execute(delete(), &mut project).expect("execute");
+ assert!(!history.can_redo());
+
+ history.clear();
+ assert!(!history.can_undo());
+ assert!(!history.can_redo());
+}
+
+#[test]
+fn preview_tracker_keeps_first_snapshot_until_end() {
+ let mut tracker = PreviewTracker::new();
+ assert!(!tracker.is_active());
+ assert_eq!(tracker.end(), None);
+
+ tracker.begin(&seconds(VIDEO_SECONDS));
+ tracker.begin(&seconds(1.0));
+ assert!(tracker.is_active());
+ assert_eq!(tracker.snapshot(), Some(&seconds(VIDEO_SECONDS)));
+
+ assert_eq!(tracker.end(), Some(seconds(VIDEO_SECONDS)));
+ assert!(!tracker.is_active());
+ assert_eq!(tracker.snapshot(), None);
+}
diff --git a/rust/crates/commands/tests/freeze_frame.rs b/rust/crates/commands/tests/freeze_frame.rs
new file mode 100644
index 00000000..614b73f7
--- /dev/null
+++ b/rust/crates/commands/tests/freeze_frame.rs
@@ -0,0 +1,155 @@
+mod common;
+
+use commands::{
+ BatchCommand, Command, InsertElementCommand, InsertElementPlacement, RetainSide,
+ ShiftSplitRemainderCommand, SplitElementsCommand,
+};
+use common::{
+ FREEZE_SECONDS, SPLIT_SECONDS, VIDEO_SECONDS, build_freeze_image_element, seconds,
+ seed_project_with_video,
+};
+use scene::{Element, ElementRef, Project};
+use time::MediaTime;
+
+fn build_freeze_batch(track_id: &str) -> BatchCommand {
+ let split = SplitElementsCommand::new(
+ vec![ElementRef {
+ track_id: track_id.to_string(),
+ element_id: "video-1".to_string(),
+ }],
+ seconds(SPLIT_SECONDS),
+ RetainSide::Both,
+ );
+ let right_side = split.right_side_handle();
+ let shift = ShiftSplitRemainderCommand::new(
+ right_side,
+ seconds(SPLIT_SECONDS) + seconds(FREEZE_SECONDS),
+ );
+ let insert = InsertElementCommand::new(
+ build_freeze_image_element(),
+ InsertElementPlacement::Explicit {
+ track_id: track_id.to_string(),
+ },
+ );
+ BatchCommand::new(vec![Box::new(split), Box::new(shift), Box::new(insert)])
+}
+
+fn sorted_main_elements(project: &Project) -> Vec<Element> {
+ let scene = project
+ .scenes
+ .iter()
+ .find(|scene| scene.id == project.current_scene_id)
+ .expect("active scene");
+ let mut elements = scene.tracks.main.elements().to_vec();
+ elements.sort_by_key(|element| element.base().start_time);
+ elements
+}
+
+fn element_type_name(element: &Element) -> &'static str {
+ match element {
+ Element::Video(_) => "video",
+ Element::Image(_) => "image",
+ Element::Text(_) => "text",
+ Element::Audio(_) => "audio",
+ Element::Sticker(_) => "sticker",
+ Element::Graphic(_) => "graphic",
+ Element::Effect(_) => "effect",
+ }
+}
+
+#[test]
+fn splits_shifts_right_side_and_inserts_into_gap() {
+ let (mut project, track_id) = seed_project_with_video();
+
+ build_freeze_batch(&track_id)
+ .execute(&mut project)
+ .expect("execute");
+
+ let elements = sorted_main_elements(&project);
+ let type_names: Vec<&str> = elements.iter().map(element_type_name).collect();
+ assert_eq!(type_names, ["video", "image", "video"]);
+
+ let [left, image, right] = &elements[..] else {
+ panic!("expected three elements");
+ };
+ assert_eq!(left.base().start_time, MediaTime::ZERO);
+ assert_eq!(left.base().duration, seconds(SPLIT_SECONDS));
+ assert_eq!(image.base().start_time, seconds(SPLIT_SECONDS));
+ assert_eq!(image.base().duration, seconds(FREEZE_SECONDS));
+ assert_eq!(
+ right.base().start_time,
+ seconds(SPLIT_SECONDS) + seconds(FREEZE_SECONDS)
+ );
+ assert_eq!(
+ right.base().duration,
+ seconds(VIDEO_SECONDS - SPLIT_SECONDS)
+ );
+}
+
+#[test]
+fn undo_restores_original_element_as_single_step() {
+ let (mut project, track_id) = seed_project_with_video();
+ let mut batch = build_freeze_batch(&track_id);
+
+ batch.execute(&mut project).expect("execute");
+ batch.undo(&mut project);
+
+ let scene = project
+ .scenes
+ .iter()
+ .find(|scene| scene.id == project.current_scene_id)
+ .expect("active scene");
+ let elements = scene.tracks.main.elements();
+ assert_eq!(elements.len(), 1);
+ assert_eq!(elements[0].base().id, "video-1");
+ assert_eq!(elements[0].base().start_time, MediaTime::ZERO);
+ assert_eq!(elements[0].base().duration, seconds(VIDEO_SECONDS));
+}
+
+#[test]
+fn redo_after_undo_reapplies_shift_to_resplit_elements() {
+ let (mut project, track_id) = seed_project_with_video();
+ let mut batch = build_freeze_batch(&track_id);
+
+ batch.execute(&mut project).expect("execute");
+ batch.undo(&mut project);
+ batch.redo(&mut project).expect("redo");
+
+ let elements = sorted_main_elements(&project);
+ let type_names: Vec<&str> = elements.iter().map(element_type_name).collect();
+ assert_eq!(type_names, ["video", "image", "video"]);
+
+ let right = elements.last().expect("right element");
+ assert!(matches!(right, Element::Video(_)));
+ assert_eq!(
+ right.base().start_time,
+ seconds(SPLIT_SECONDS) + seconds(FREEZE_SECONDS)
+ );
+}
+
+#[test]
+fn no_op_when_split_produces_no_right_side() {
+ let (mut project, track_id) = seed_project_with_video();
+
+ let split = SplitElementsCommand::new(
+ vec![ElementRef {
+ track_id: track_id.to_string(),
+ element_id: "video-1".to_string(),
+ }],
+ MediaTime::ZERO,
+ RetainSide::Both,
+ );
+ let right_side = split.right_side_handle();
+ let shift = ShiftSplitRemainderCommand::new(right_side, seconds(FREEZE_SECONDS));
+ let mut batch = BatchCommand::new(vec![Box::new(split), Box::new(shift)]);
+
+ batch.execute(&mut project).expect("execute");
+ batch.undo(&mut project);
+
+ let scene = project
+ .scenes
+ .iter()
+ .find(|scene| scene.id == project.current_scene_id)
+ .expect("active scene");
+ assert_eq!(scene.tracks.main.elements().len(), 1);
+}
diff --git a/rust/crates/effect-defs/src/definitions/blur.rs b/rust/crates/effect-defs/src/definitions/blur.rs
new file mode 100644
index 00000000..8ad0dd55
--- /dev/null
+++ b/rust/crates/effect-defs/src/definitions/blur.rs
@@ -0,0 +1,147 @@
+use std::collections::HashMap;
+
+use effects::{EffectPass, UniformValue};
+use scene::params::{ParamValue, ParamValues};
+
+use crate::math::{js_max, js_min, js_parse_float};
+use crate::params::{NumberParamDefinition, ParamDefinition};
+use crate::types::{EffectDefinition, EffectPassTemplate, EffectRendererConfig, PassContext};
+
+pub const GAUSSIAN_BLUR_SHADER: &str = "gaussian-blur";
+
+const MAX_SINGLE_PASS_SIGMA: f64 = 10.0;
+const MAX_STEP: f64 = 4.0;
+const MAX_EFFECTIVE_SIGMA: f64 = MAX_SINGLE_PASS_SIGMA * MAX_STEP;
+const MAX_ITERATIONS: f64 = 8.0;
+
+pub const INTENSITY_TO_SIGMA_DIVISOR: f64 = 5.0;
+
+pub fn build_gaussian_blur_passes(sigma_x: f64, sigma_y: f64) -> Vec<EffectPass> {
+ let max_sigma = js_max(sigma_x, sigma_y);
+ if max_sigma < 0.001 {
+ return Vec::new();
+ }
+
+ let iterations = js_min(
+ MAX_ITERATIONS,
+ js_max(
+ 1.0,
+ ((max_sigma * max_sigma) / (MAX_EFFECTIVE_SIGMA * MAX_EFFECTIVE_SIGMA)).ceil(),
+ ),
+ );
+ let iteration_count = iterations.max(0.0) as usize;
+ let per_pass_sigma_x = sigma_x / iterations.sqrt();
+ let per_pass_sigma_y = sigma_y / iterations.sqrt();
+ let step_x = js_max(1.0, per_pass_sigma_x / MAX_SINGLE_PASS_SIGMA);
+ let step_y = js_max(1.0, per_pass_sigma_y / MAX_SINGLE_PASS_SIGMA);
+
+ let mut passes = Vec::with_capacity(iteration_count * 2);
+ for _ in 0..iteration_count {
+ passes.push(blur_pass(per_pass_sigma_x, step_x, [1.0, 0.0]));
+ passes.push(blur_pass(per_pass_sigma_y, step_y, [0.0, 1.0]));
+ }
+ passes
+}
+
+pub fn intensity_to_sigma(intensity: f64, resolution: f64, reference: f64) -> f64 {
+ (intensity / INTENSITY_TO_SIGMA_DIVISOR) * (resolution / reference)
+}
+
+pub fn parse_intensity(effect_params: &ParamValues) -> f64 {
+ match effect_params.get("intensity") {
+ Some(ParamValue::Number(value)) => *value,
+ Some(ParamValue::String(value)) => js_parse_float(value),
+ Some(ParamValue::Bool(true)) => js_parse_float("true"),
+ Some(ParamValue::Bool(false)) => js_parse_float("false"),
+ None => js_parse_float("undefined"),
+ }
+}
+
+pub fn blur_effect_definition() -> EffectDefinition {
+ EffectDefinition {
+ effect_type: "blur",
+ name: "Blur",
+ keywords: &["blur", "soft", "defocus"],
+ params: vec![ParamDefinition::Number(NumberParamDefinition::new(
+ "intensity",
+ "Intensity",
+ 15.0,
+ 0.0,
+ Some(100.0),
+ 1.0,
+ ))],
+ renderer: EffectRendererConfig {
+ passes: vec![
+ EffectPassTemplate {
+ shader: GAUSSIAN_BLUR_SHADER,
+ uniforms: horizontal_template_uniforms,
+ },
+ EffectPassTemplate {
+ shader: GAUSSIAN_BLUR_SHADER,
+ uniforms: vertical_template_uniforms,
+ },
+ ],
+ build_passes: Some(build_passes_from_params),
+ },
+ }
+}
+
+fn blur_pass(sigma: f64, step: f64, direction: [f32; 2]) -> EffectPass {
+ EffectPass {
+ shader: GAUSSIAN_BLUR_SHADER.to_string(),
+ uniforms: HashMap::from([
+ ("u_sigma".to_string(), UniformValue::Number(sigma as f32)),
+ ("u_step".to_string(), UniformValue::Number(step as f32)),
+ (
+ "u_direction".to_string(),
+ UniformValue::Vector(direction.to_vec()),
+ ),
+ ]),
+ }
+}
+
+fn horizontal_template_uniforms(context: &PassContext) -> HashMap<String, UniformValue> {
+ let sigma = js_max(
+ intensity_to_sigma(
+ parse_intensity(context.effect_params),
+ context.width as f64,
+ 1920.0,
+ ),
+ 0.001,
+ );
+ HashMap::from([
+ ("u_sigma".to_string(), UniformValue::Number(sigma as f32)),
+ ("u_step".to_string(), UniformValue::Number(1.0)),
+ (
+ "u_direction".to_string(),
+ UniformValue::Vector(vec![1.0, 0.0]),
+ ),
+ ])
+}
+
+fn vertical_template_uniforms(context: &PassContext) -> HashMap<String, UniformValue> {
+ let sigma = js_max(
+ intensity_to_sigma(
+ parse_intensity(context.effect_params),
+ context.height as f64,
+ 1080.0,
+ ),
+ 0.001,
+ );
+ HashMap::from([
+ ("u_sigma".to_string(), UniformValue::Number(sigma as f32)),
+ ("u_step".to_string(), UniformValue::Number(1.0)),
+ (
+ "u_direction".to_string(),
+ UniformValue::Vector(vec![0.0, 1.0]),
+ ),
+ ])
+}
+
+fn build_passes_from_params(context: &PassContext) -> Vec<EffectPass> {
+ let intensity = parse_intensity(context.effect_params);
+ build_gaussian_blur_passes(
+ intensity_to_sigma(intensity, context.width as f64, 1920.0),
+ intensity_to_sigma(intensity, context.height as f64, 1080.0),
+ )
+}
diff --git a/rust/crates/effect-defs/src/definitions/mod.rs b/rust/crates/effect-defs/src/definitions/mod.rs
new file mode 100644
index 00000000..59f2575c
--- /dev/null
+++ b/rust/crates/effect-defs/src/definitions/mod.rs
@@ -0,0 +1,12 @@
+pub mod blur;
+
+use crate::registry::EffectsRegistry;
+
+pub fn register_default_effects(registry: &mut EffectsRegistry) {
+ for definition in [blur::blur_effect_definition()] {
+ if registry.has(definition.effect_type) {
+ continue;
+ }
+ registry.register(definition);
+ }
+}
diff --git a/rust/crates/effect-defs/src/types.rs b/rust/crates/effect-defs/src/types.rs
index c9815fd7..2b1e79d2 100644
--- a/rust/crates/effect-defs/src/types.rs
+++ b/rust/crates/effect-defs/src/types.rs
@@ -11,16 +11,19 @@ pub struct PassContext<'a> {
pub height: u32,
}
+#[derive(Debug)]
pub struct EffectPassTemplate {
pub shader: &'static str,
pub uniforms: fn(&PassContext) -> HashMap<String, UniformValue>,
}
+#[derive(Debug)]
pub struct EffectRendererConfig {
pub passes: Vec<EffectPassTemplate>,
pub build_passes: Option<fn(&PassContext) -> Vec<EffectPass>>,
}
+#[derive(Debug)]
pub struct EffectDefinition {
pub effect_type: &'static str,
pub name: &'static str,
diff --git a/rust/crates/effect-defs/tests/effect_defs.rs b/rust/crates/effect-defs/tests/effect_defs.rs
new file mode 100644
index 00000000..048ceeb2
--- /dev/null
+++ b/rust/crates/effect-defs/tests/effect_defs.rs
@@ -0,0 +1,270 @@
+use effect_defs::{
+ EffectPassTemplate, EffectRendererConfig, EffectsRegistry, GAUSSIAN_BLUR_SHADER,
+ ParamDefinition, blur_effect_definition, build_default_param_values,
+ build_gaussian_blur_passes, intensity_to_sigma, params_with_defaults, parse_intensity,
+ register_default_effects, resolve_effect_passes,
+};
+use effects::{EffectPass, UniformValue};
+use scene::params::{ParamValue, ParamValues};
+
+fn number_uniform(pass: &EffectPass, key: &str) -> f32 {
+ match pass.uniforms.get(key) {
+ Some(UniformValue::Number(value)) => *value,
+ other => panic!("expected number uniform {key}, got {other:?}"),
+ }
+}
+
+fn vector_uniform(pass: &EffectPass, key: &str) -> Vec<f32> {
+ match pass.uniforms.get(key) {
+ Some(UniformValue::Vector(value)) => value.clone(),
+ other => panic!("expected vector uniform {key}, got {other:?}"),
+ }
+}
+
+fn params_with_intensity(value: f64) -> ParamValues {
+ ParamValues::from([("intensity".to_string(), ParamValue::Number(value))])
+}
+
+fn assert_close(actual: f32, expected: f64) {
+ assert!(
+ (actual as f64 - expected).abs() < 1e-5,
+ "expected {expected}, got {actual}"
+ );
+}
+
+#[test]
+fn intensity_to_sigma_scales_by_resolution_ratio() {
+ assert_eq!(intensity_to_sigma(15.0, 1920.0, 1920.0), 3.0);
+ assert_eq!(intensity_to_sigma(10.0, 960.0, 1920.0), 1.0);
+ assert_eq!(intensity_to_sigma(0.0, 1920.0, 1920.0), 0.0);
+ assert_eq!(intensity_to_sigma(100.0, 1080.0, 1080.0), 20.0);
+}
+
+#[test]
+fn blur_passes_below_threshold_sigma_are_empty() {
+ assert!(build_gaussian_blur_passes(0.0, 0.0).is_empty());
+ assert!(build_gaussian_blur_passes(0.0009, 0.0009).is_empty());
+ assert!(build_gaussian_blur_passes(f64::NAN, 3.0).is_empty());
+}
+
+#[test]
+fn blur_passes_single_iteration_at_default_intensity() {
+ let passes = build_gaussian_blur_passes(3.0, 3.0);
+ assert_eq!(passes.len(), 2);
+
+ assert_eq!(passes[0].shader, GAUSSIAN_BLUR_SHADER);
+ assert_close(number_uniform(&passes[0], "u_sigma"), 3.0);
+ assert_close(number_uniform(&passes[0], "u_step"), 1.0);
+ assert_eq!(vector_uniform(&passes[0], "u_direction"), vec![1.0, 0.0]);
+
+ assert_eq!(passes[1].shader, GAUSSIAN_BLUR_SHADER);
+ assert_close(number_uniform(&passes[1], "u_sigma"), 3.0);
+ assert_close(number_uniform(&passes[1], "u_step"), 1.0);
+ assert_eq!(vector_uniform(&passes[1], "u_direction"), vec![0.0, 1.0]);
+}
+
+#[test]
+fn blur_passes_step_up_when_sigma_exceeds_single_pass_limit() {
+ let passes = build_gaussian_blur_passes(20.0, 20.0);
+ assert_eq!(passes.len(), 2);
+ assert_close(number_uniform(&passes[0], "u_sigma"), 20.0);
+ assert_close(number_uniform(&passes[0], "u_step"), 2.0);
+ assert_close(number_uniform(&passes[1], "u_step"), 2.0);
+}
+
+#[test]
+fn blur_passes_iterate_for_large_sigma() {
+ let passes = build_gaussian_blur_passes(50.0, 50.0);
+ assert_eq!(passes.len(), 4);
+ assert_close(number_uniform(&passes[0], "u_sigma"), 35.35533905932738);
+ assert_close(number_uniform(&passes[0], "u_step"), 3.5355339059327378);
+ assert_close(number_uniform(&passes[2], "u_sigma"), 35.35533905932738);
+}
+
+#[test]
+fn blur_passes_cap_at_max_iterations() {
+ let passes = build_gaussian_blur_passes(400.0, 400.0);
+ assert_eq!(passes.len(), 16);
+ assert_close(number_uniform(&passes[0], "u_sigma"), 141.4213562373095);
+ assert_close(number_uniform(&passes[0], "u_step"), 14.14213562373095);
+}
+
+#[test]
+fn blur_passes_support_anisotropic_sigma() {
+ let passes = build_gaussian_blur_passes(3.0, 0.0);
+ assert_eq!(passes.len(), 2);
+ assert_close(number_uniform(&passes[0], "u_sigma"), 3.0);
+ assert_close(number_uniform(&passes[1], "u_sigma"), 0.0);
+ assert_close(number_uniform(&passes[1], "u_step"), 1.0);
+}
+
+#[test]
+fn parse_intensity_matches_js_coercion() {
+ assert_eq!(parse_intensity(&params_with_intensity(25.0)), 25.0);
+ assert_eq!(
+ parse_intensity(&ParamValues::from([(
+ "intensity".to_string(),
+ ParamValue::String("25".to_string())
+ )])),
+ 25.0
+ );
+ assert_eq!(
+ parse_intensity(&ParamValues::from([(
+ "intensity".to_string(),
+ ParamValue::String("25abc".to_string())
+ )])),
+ 25.0
+ );
+ assert!(
+ parse_intensity(&ParamValues::from([(
+ "intensity".to_string(),
+ ParamValue::Bool(true)
+ )]))
+ .is_nan()
+ );
+ assert!(parse_intensity(&ParamValues::new()).is_nan());
+ assert!(
+ parse_intensity(&ParamValues::from([(
+ "intensity".to_string(),
+ ParamValue::String("abc".to_string())
+ )]))
+ .is_nan()
+ );
+}
+
+#[test]
+fn blur_definition_matches_web_schema() {
+ let definition = blur_effect_definition();
+ assert_eq!(definition.effect_type, "blur");
+ assert_eq!(definition.name, "Blur");
+ assert_eq!(definition.keywords, ["blur", "soft", "defocus"]);
+ assert_eq!(definition.params.len(), 1);
+
+ let ParamDefinition::Number(intensity) = &definition.params[0] else {
+ panic!("expected number param");
+ };
+ assert_eq!(intensity.key, "intensity");
+ assert_eq!(intensity.label, "Intensity");
+ assert_eq!(intensity.default_value, 15.0);
+ assert_eq!(intensity.min, 0.0);
+ assert_eq!(intensity.max, Some(100.0));
+ assert_eq!(intensity.step, 1.0);
+
+ assert_eq!(definition.renderer.passes.len(), 2);
+ assert!(definition.renderer.build_passes.is_some());
+ for template in &definition.renderer.passes {
+ assert_eq!(template.shader, GAUSSIAN_BLUR_SHADER);
+ }
+}
+
+#[test]
+fn registry_resolves_definitions_by_type() {
+ let mut registry = EffectsRegistry::new();
+ register_default_effects(&mut registry);
+ register_default_effects(&mut registry);
+
+ assert!(registry.has("blur"));
+ assert!(!registry.has("unknown"));
+ assert_eq!(registry.get_all().len(), 1);
+ assert_eq!(registry.get("blur").unwrap().name, "Blur");
+
+ let error = registry.get("unknown").unwrap_err();
+ assert_eq!(error.to_string(), "Unknown effect: unknown");
+}
+
+#[test]
+fn default_param_values_come_from_definition() {
+ let definition = blur_effect_definition();
+ let defaults = build_default_param_values(&definition.params);
+ assert_eq!(
+ defaults.get("intensity"),
+ Some(&ParamValue::Number(15.0))
+ );
+
+ let merged = params_with_defaults(&definition, &ParamValues::new());
+ assert_eq!(merged.get("intensity"), Some(&ParamValue::Number(15.0)));
+
+ let overridden = params_with_defaults(&definition, &params_with_intensity(42.0));
+ assert_eq!(
+ overridden.get("intensity"),
+ Some(&ParamValue::Number(42.0))
+ );
+}
+
+#[test]
+fn resolve_effect_passes_prefers_build_passes() {
+ let definition = blur_effect_definition();
+ let passes = resolve_effect_passes(&definition, &params_with_intensity(15.0), 1920, 1080);
+ assert_eq!(passes.len(), 2);
+ assert_close(number_uniform(&passes[0], "u_sigma"), 3.0);
+ assert_close(number_uniform(&passes[1], "u_sigma"), 3.0);
+ assert_eq!(vector_uniform(&passes[0], "u_direction"), vec![1.0, 0.0]);
+ assert_eq!(vector_uniform(&passes[1], "u_direction"), vec![0.0, 1.0]);
+
+ let stepped = resolve_effect_passes(&definition, &params_with_intensity(100.0), 1920, 1080);
+ assert_eq!(stepped.len(), 2);
+ assert_close(number_uniform(&stepped[0], "u_sigma"), 20.0);
+ assert_close(number_uniform(&stepped[0], "u_step"), 2.0);
+}
+
+#[test]
+fn resolve_effect_passes_with_defaults_applied_matches_explicit_params() {
+ let definition = blur_effect_definition();
+ let merged = params_with_defaults(&definition, &ParamValues::new());
+ let from_defaults = resolve_effect_passes(&definition, &merged, 1920, 1080);
+ let explicit = resolve_effect_passes(&definition, &params_with_intensity(15.0), 1920, 1080);
+ assert_eq!(from_defaults.len(), explicit.len());
+ assert_close(
+ number_uniform(&from_defaults[0], "u_sigma"),
+ number_uniform(&explicit[0], "u_sigma") as f64,
+ );
+}
+
+#[test]
+fn resolve_effect_passes_without_intensity_produces_no_passes() {
+ let definition = blur_effect_definition();
+ let passes = resolve_effect_passes(&definition, &ParamValues::new(), 1920, 1080);
+ assert!(passes.is_empty());
+}
+
+#[test]
+fn resolve_effect_passes_falls_back_to_pass_templates() {
+ let mut definition = blur_effect_definition();
+ definition.renderer.build_passes = None;
+
+ let passes = resolve_effect_passes(&definition, &params_with_intensity(15.0), 1920, 1080);
+ assert_eq!(passes.len(), 2);
+ assert_close(number_uniform(&passes[0], "u_sigma"), 3.0);
+ assert_close(number_uniform(&passes[0], "u_step"), 1.0);
+ assert_eq!(vector_uniform(&passes[0], "u_direction"), vec![1.0, 0.0]);
+ assert_close(number_uniform(&passes[1], "u_sigma"), 3.0);
+ assert_eq!(vector_uniform(&passes[1], "u_direction"), vec![0.0, 1.0]);
+
+ let zero = resolve_effect_passes(&definition, &params_with_intensity(0.0), 1920, 1080);
+ assert_eq!(zero.len(), 2);
+ assert_close(number_uniform(&zero[0], "u_sigma"), 0.001);
+ assert_close(number_uniform(&zero[1], "u_sigma"), 0.001);
+}
+
+#[test]
+fn template_path_scales_sigma_with_resolution() {
+ let definition = blur_effect_definition();
+ let renderer = EffectRendererConfig {
+ passes: vec![EffectPassTemplate {
+ shader: GAUSSIAN_BLUR_SHADER,
+ uniforms: definition.renderer.passes[0].uniforms,
+ }],
+ build_passes: None,
+ };
+ let template_only = effect_defs::EffectDefinition {
+ effect_type: definition.effect_type,
+ name: definition.name,
+ keywords: definition.keywords,
+ params: definition.params.clone(),
+ renderer,
+ };
+
+ let passes = resolve_effect_passes(&template_only, &params_with_intensity(10.0), 960, 540);
+ assert_eq!(passes.len(), 1);
+ assert_close(number_uniform(&passes[0], "u_sigma"), 1.0);
+}
diff --git a/rust/crates/graphics/Cargo.toml b/rust/crates/graphics/Cargo.toml
index 93a5c26b..23db7c6e 100644
--- a/rust/crates/graphics/Cargo.toml
+++ b/rust/crates/graphics/Cargo.toml
@@ -5,3 +5,5 @@ edition = "2024"
[dependencies]
scene = { version = "0.1.0", path = "../scene" }
+tiny-skia = "0.11.4"
+thiserror = "2.0.18"
diff --git a/rust/crates/graphics/src/color.rs b/rust/crates/graphics/src/color.rs
new file mode 100644
index 00000000..25a94a96
--- /dev/null
+++ b/rust/crates/graphics/src/color.rs
@@ -0,0 +1,487 @@
+#[derive(Debug, Clone, Copy, PartialEq)]
+pub struct Rgba {
+ pub r: f64,
+ pub g: f64,
+ pub b: f64,
+ pub a: f64,
+}
+
+const NAMED_COLORS: &[(&str, u8, u8, u8)] = &[
+ ("aliceblue", 240, 248, 255),
+ ("antiquewhite", 250, 235, 215),
+ ("aqua", 0, 255, 255),
+ ("aquamarine", 127, 255, 212),
+ ("azure", 240, 255, 255),
+ ("beige", 245, 245, 220),
+ ("bisque", 255, 228, 196),
+ ("black", 0, 0, 0),
+ ("blanchedalmond", 255, 235, 205),
+ ("blue", 0, 0, 255),
+ ("blueviolet", 138, 43, 226),
+ ("brown", 165, 42, 42),
+ ("burlywood", 222, 184, 135),
+ ("cadetblue", 95, 158, 160),
+ ("chartreuse", 127, 255, 0),
+ ("chocolate", 210, 105, 30),
+ ("coral", 255, 127, 80),
+ ("cornflowerblue", 100, 149, 237),
+ ("cornsilk", 255, 248, 220),
+ ("crimson", 220, 20, 60),
+ ("cyan", 0, 255, 255),
+ ("darkblue", 0, 0, 139),
+ ("darkcyan", 0, 139, 139),
+ ("darkgoldenrod", 184, 134, 11),
+ ("darkgray", 169, 169, 169),
+ ("darkgreen", 0, 100, 0),
+ ("darkgrey", 169, 169, 169),
+ ("darkkhaki", 189, 183, 107),
+ ("darkmagenta", 139, 0, 139),
+ ("darkolivegreen", 85, 107, 47),
+ ("darkorange", 255, 140, 0),
+ ("darkorchid", 153, 50, 204),
+ ("darkred", 139, 0, 0),
+ ("darksalmon", 233, 150, 122),
+ ("darkseagreen", 143, 188, 143),
+ ("darkslateblue", 72, 61, 139),
+ ("darkslategray", 47, 79, 79),
+ ("darkslategrey", 47, 79, 79),
+ ("darkturquoise", 0, 206, 209),
+ ("darkviolet", 148, 0, 211),
+ ("deeppink", 255, 20, 147),
+ ("deepskyblue", 0, 191, 255),
+ ("dimgray", 105, 105, 105),
+ ("dimgrey", 105, 105, 105),
+ ("dodgerblue", 30, 144, 255),
+ ("firebrick", 178, 34, 34),
+ ("floralwhite", 255, 250, 240),
+ ("forestgreen", 34, 139, 34),
+ ("fuchsia", 255, 0, 255),
+ ("gainsboro", 220, 220, 220),
+ ("ghostwhite", 248, 248, 255),
+ ("gold", 255, 215, 0),
+ ("goldenrod", 218, 165, 32),
+ ("gray", 128, 128, 128),
+ ("green", 0, 128, 0),
+ ("greenyellow", 173, 255, 47),
+ ("grey", 128, 128, 128),
+ ("honeydew", 240, 255, 240),
+ ("hotpink", 255, 105, 180),
+ ("indianred", 205, 92, 92),
+ ("indigo", 75, 0, 130),
+ ("ivory", 255, 255, 240),
+ ("khaki", 240, 230, 140),
+ ("lavender", 230, 230, 250),
+ ("lavenderblush", 255, 240, 245),
+ ("lawngreen", 124, 252, 0),
+ ("lemonchiffon", 255, 250, 205),
+ ("lightblue", 173, 216, 230),
+ ("lightcoral", 240, 128, 128),
+ ("lightcyan", 224, 255, 255),
+ ("lightgoldenrodyellow", 250, 250, 210),
+ ("lightgray", 211, 211, 211),
+ ("lightgreen", 144, 238, 144),
+ ("lightgrey", 211, 211, 211),
+ ("lightpink", 255, 182, 193),
+ ("lightsalmon", 255, 160, 122),
+ ("lightseagreen", 32, 178, 170),
+ ("lightskyblue", 135, 206, 250),
+ ("lightslategray", 119, 136, 153),
+ ("lightslategrey", 119, 136, 153),
+ ("lightsteelblue", 176, 196, 222),
+ ("lightyellow", 255, 255, 224),
+ ("lime", 0, 255, 0),
+ ("limegreen", 50, 205, 50),
+ ("linen", 250, 240, 230),
+ ("magenta", 255, 0, 255),
+ ("maroon", 128, 0, 0),
+ ("mediumaquamarine", 102, 205, 170),
+ ("mediumblue", 0, 0, 205),
+ ("mediumorchid", 186, 85, 211),
+ ("mediumpurple", 147, 112, 219),
+ ("mediumseagreen", 60, 179, 113),
+ ("mediumslateblue", 123, 104, 238),
+ ("mediumspringgreen", 0, 250, 154),
+ ("mediumturquoise", 72, 209, 204),
+ ("mediumvioletred", 199, 21, 133),
+ ("midnightblue", 25, 25, 112),
+ ("mintcream", 245, 255, 250),
+ ("mistyrose", 255, 228, 225),
+ ("moccasin", 255, 228, 181),
+ ("navajowhite", 255, 222, 173),
+ ("navy", 0, 0, 128),
+ ("oldlace", 253, 245, 230),
+ ("olive", 128, 128, 0),
+ ("olivedrab", 107, 142, 35),
+ ("orange", 255, 165, 0),
+ ("orangered", 255, 69, 0),
+ ("orchid", 218, 112, 214),
+ ("palegoldenrod", 238, 232, 170),
+ ("palegreen", 152, 251, 152),
+ ("paleturquoise", 175, 238, 238),
+ ("palevioletred", 219, 112, 147),
+ ("papayawhip", 255, 239, 213),
+ ("peachpuff", 255, 218, 185),
+ ("peru", 205, 133, 63),
+ ("pink", 255, 192, 203),
+ ("plum", 221, 160, 221),
+ ("powderblue", 176, 224, 230),
+ ("purple", 128, 0, 128),
+ ("rebeccapurple", 102, 51, 153),
+ ("red", 255, 0, 0),
+ ("rosybrown", 188, 143, 143),
+ ("royalblue", 65, 105, 225),
+ ("saddlebrown", 139, 69, 19),
+ ("salmon", 250, 128, 114),
+ ("sandybrown", 244, 164, 96),
+ ("seagreen", 46, 139, 87),
+ ("seashell", 255, 245, 238),
+ ("sienna", 160, 82, 45),
+ ("silver", 192, 192, 192),
+ ("skyblue", 135, 206, 235),
+ ("slateblue", 106, 90, 205),
+ ("slategray", 112, 128, 144),
+ ("slategrey", 112, 128, 144),
+ ("snow", 255, 250, 250),
+ ("springgreen", 0, 255, 127),
+ ("steelblue", 70, 130, 180),
+ ("tan", 210, 180, 140),
+ ("teal", 0, 128, 128),
+ ("thistle", 216, 191, 216),
+ ("tomato", 255, 99, 71),
+ ("turquoise", 64, 224, 208),
+ ("violet", 238, 130, 238),
+ ("wheat", 245, 222, 179),
+ ("white", 255, 255, 255),
+ ("whitesmoke", 245, 245, 245),
+ ("yellow", 255, 255, 0),
+ ("yellowgreen", 154, 205, 50),
+];
+
+pub fn named_color(name: &str) -> Option<Rgba> {
+ let lowered = name.to_lowercase();
+ if lowered == "transparent" {
+ return Some(Rgba { r: 0.0, g: 0.0, b: 0.0, a: 0.0 });
+ }
+ NAMED_COLORS
+ .binary_search_by(|(key, _, _, _)| (*key).cmp(lowered.as_str()))
+ .ok()
+ .map(|index| {
+ let (_, r, g, b) = NAMED_COLORS[index];
+ Rgba {
+ r: f64::from(r) / 255.0,
+ g: f64::from(g) / 255.0,
+ b: f64::from(b) / 255.0,
+ a: 1.0,
+ }
+ })
+}
+
+fn parse_hex_color(hex: &str) -> Option<Rgba> {
+ if !hex.chars().all(|c| c.is_ascii_hexdigit()) {
+ return None;
+ }
+ match hex.len() {
+ 3 => {
+ let r = u8::from_str_radix(&hex[0..1].repeat(2), 16).ok()?;
+ let g = u8::from_str_radix(&hex[1..2].repeat(2), 16).ok()?;
+ let b = u8::from_str_radix(&hex[2..3].repeat(2), 16).ok()?;
+ Some(Rgba {
+ r: f64::from(r) / 255.0,
+ g: f64::from(g) / 255.0,
+ b: f64::from(b) / 255.0,
+ a: 1.0,
+ })
+ }
+ 4 => {
+ let r = u8::from_str_radix(&hex[0..1].repeat(2), 16).ok()?;
+ let g = u8::from_str_radix(&hex[1..2].repeat(2), 16).ok()?;
+ let b = u8::from_str_radix(&hex[2..3].repeat(2), 16).ok()?;
+ let a = u8::from_str_radix(&hex[3..4].repeat(2), 16).ok()?;
+ Some(Rgba {
+ r: f64::from(r) / 255.0,
+ g: f64::from(g) / 255.0,
+ b: f64::from(b) / 255.0,
+ a: f64::from(a) / 255.0,
+ })
+ }
+ 6 => {
+ let r = u8::from_str_radix(&hex[0..2], 16).ok()?;
+ let g = u8::from_str_radix(&hex[2..4], 16).ok()?;
+ let b = u8::from_str_radix(&hex[4..6], 16).ok()?;
+ Some(Rgba {
+ r: f64::from(r) / 255.0,
+ g: f64::from(g) / 255.0,
+ b: f64::from(b) / 255.0,
+ a: 1.0,
+ })
+ }
+ 8 => {
+ let r = u8::from_str_radix(&hex[0..2], 16).ok()?;
+ let g = u8::from_str_radix(&hex[2..4], 16).ok()?;
+ let b = u8::from_str_radix(&hex[4..6], 16).ok()?;
+ let a = u8::from_str_radix(&hex[6..8], 16).ok()?;
+ Some(Rgba {
+ r: f64::from(r) / 255.0,
+ g: f64::from(g) / 255.0,
+ b: f64::from(b) / 255.0,
+ a: f64::from(a) / 255.0,
+ })
+ }
+ _ => None,
+ }
+}
+
+fn parse_channel(text: &str) -> Option<f64> {
+ let trimmed = text.trim();
+ if let Some(percent) = trimmed.strip_suffix('%') {
+ return percent.trim().parse::<f64>().ok().map(|v| v * 255.0 / 100.0);
+ }
+ trimmed.parse::<f64>().ok()
+}
+
+fn parse_alpha(text: &str) -> Option<f64> {
+ let trimmed = text.trim();
+ if let Some(percent) = trimmed.strip_suffix('%') {
+ return percent.trim().parse::<f64>().ok().map(|v| v / 100.0);
+ }
+ trimmed.parse::<f64>().ok()
+}
+
+fn split_function_args(body: &str) -> Vec<String> {
+ if body.contains(',') {
+ return body.split(',').map(|part| part.trim().to_string()).collect();
+ }
+ let mut parts: Vec<String> = Vec::new();
+ let mut alpha: Option<String> = None;
+ for token in body.split_whitespace() {
+ if let Some(rest) = token.strip_prefix('/') {
+ alpha = Some(rest.trim().to_string());
+ continue;
+ }
+ parts.push(token.to_string());
+ }
+ if let Some(alpha) = alpha {
+ if !alpha.is_empty() {
+ parts.push(alpha);
+ }
+ }
+ parts
+}
+
+fn parse_rgb_function(body: &str) -> Option<Rgba> {
+ let parts = split_function_args(body);
+ if parts.len() < 3 || parts.len() > 4 {
+ return None;
+ }
+ let r = parse_channel(&parts[0])?;
+ let g = parse_channel(&parts[1])?;
+ let b = parse_channel(&parts[2])?;
+ let a = if parts.len() == 4 {
+ parse_alpha(&parts[3])?
+ } else {
+ 1.0
+ };
+ Some(Rgba {
+ r: r / 255.0,
+ g: g / 255.0,
+ b: b / 255.0,
+ a,
+ })
+}
+
+fn parse_hue(text: &str) -> Option<f64> {
+ let trimmed = text.trim();
+ if let Some(deg) = trimmed.strip_suffix("deg") {
+ return deg.trim().parse::<f64>().ok();
+ }
+ if let Some(turn) = trimmed.strip_suffix("turn") {
+ return turn.trim().parse::<f64>().ok().map(|v| v * 360.0);
+ }
+ if let Some(rad) = trimmed.strip_suffix("rad") {
+ return rad.trim().parse::<f64>().ok().map(|v| v.to_degrees());
+ }
+ if let Some(grad) = trimmed.strip_suffix("grad") {
+ return grad.trim().parse::<f64>().ok().map(|v| v * 0.9);
+ }
+ trimmed.parse::<f64>().ok()
+}
+
+fn parse_percentage(text: &str) -> Option<f64> {
+ text.trim()
+ .strip_suffix('%')
+ .and_then(|v| v.trim().parse::<f64>().ok())
+ .map(|v| v / 100.0)
+}
+
+pub fn hsl_to_rgb(h: f64, s: f64, l: f64) -> (f64, f64, f64) {
+ let hue = h.rem_euclid(360.0);
+ let sat = s.clamp(0.0, 1.0);
+ let light = l.clamp(0.0, 1.0);
+ let chroma = (1.0 - (2.0 * light - 1.0).abs()) * sat;
+ let segment = hue / 60.0;
+ let x = chroma * (1.0 - (segment % 2.0 - 1.0).abs());
+ let (r1, g1, b1) = match segment as u32 {
+ 0 => (chroma, x, 0.0),
+ 1 => (x, chroma, 0.0),
+ 2 => (0.0, chroma, x),
+ 3 => (0.0, x, chroma),
+ 4 => (x, 0.0, chroma),
+ _ => (chroma, 0.0, x),
+ };
+ let m = light - chroma / 2.0;
+ (r1 + m, g1 + m, b1 + m)
+}
+
+fn parse_hsl_function(body: &str) -> Option<Rgba> {
+ let parts = split_function_args(body);
+ if parts.len() < 3 || parts.len() > 4 {
+ return None;
+ }
+ let h = parse_hue(&parts[0])?;
+ let s = parse_percentage(&parts[1])?;
+ let l = parse_percentage(&parts[2])?;
+ let a = if parts.len() == 4 {
+ parse_alpha(&parts[3])?
+ } else {
+ 1.0
+ };
+ let (r, g, b) = hsl_to_rgb(h, s, l);
+ Some(Rgba { r, g, b, a })
+}
+
+fn clamp01(value: f64) -> f64 {
+ value.clamp(0.0, 1.0)
+}
+
+pub fn parse_css_color(input: &str) -> Option<Rgba> {
+ let text = input.trim();
+ if let Some(hex) = text.strip_prefix('#') {
+ return parse_hex_color(hex);
+ }
+ let lowered = text.to_lowercase();
+ let function_body = |names: &[&str]| -> Option<&str> {
+ for name in names {
+ if let Some(rest) = lowered.strip_prefix(name) {
+ return rest.strip_suffix(')');
+ }
+ }
+ None
+ };
+ let parsed = if let Some(body) = function_body(&["rgba(", "rgb("]) {
+ parse_rgb_function(body)
+ } else if let Some(body) = function_body(&["hsla(", "hsl("]) {
+ parse_hsl_function(body)
+ } else {
+ named_color(&lowered)
+ }?;
+ Some(Rgba {
+ r: clamp01(parsed.r),
+ g: clamp01(parsed.g),
+ b: clamp01(parsed.b),
+ a: clamp01(parsed.a),
+ })
+}
+
+fn is_digits_and_dots(text: &str) -> bool {
+ !text.is_empty() && text.chars().all(|c| c.is_ascii_digit() || c == '.')
+}
+
+pub fn is_transparent(color: &str) -> bool {
+ let lower = color.to_lowercase();
+ let trimmed = lower.trim();
+ if trimmed == "transparent" {
+ return true;
+ }
+ let Some(body) = trimmed
+ .strip_prefix("rgba(")
+ .and_then(|rest| rest.strip_suffix(')'))
+ else {
+ return false;
+ };
+ let parts: Vec<&str> = body.split(',').map(str::trim).collect();
+ if parts.len() != 4 {
+ return false;
+ }
+ if !parts[..3].iter().all(|part| is_digits_and_dots(part)) {
+ return false;
+ }
+ if !is_digits_and_dots(parts[3]) {
+ return false;
+ }
+ parts[3].parse::<f64>().is_ok_and(|alpha| alpha == 0.0)
+}
+
+pub fn parse_color_to_rgb(color: &str) -> Option<Rgba> {
+ let lower = color.to_lowercase();
+ let trimmed = lower.trim();
+ if let Some(hex) = trimmed.strip_prefix('#') {
+ if hex.len() < 3 || hex.len() > 8 || !hex.chars().all(|c| c.is_ascii_hexdigit()) {
+ return None;
+ }
+ match hex.len() {
+ 3 => {
+ let r = u8::from_str_radix(&hex[0..1].repeat(2), 16).ok()?;
+ let g = u8::from_str_radix(&hex[1..2].repeat(2), 16).ok()?;
+ let b = u8::from_str_radix(&hex[2..3].repeat(2), 16).ok()?;
+ return Some(Rgba {
+ r: f64::from(r),
+ g: f64::from(g),
+ b: f64::from(b),
+ a: 1.0,
+ });
+ }
+ 6 => {
+ let r = u8::from_str_radix(&hex[0..2], 16).ok()?;
+ let g = u8::from_str_radix(&hex[2..4], 16).ok()?;
+ let b = u8::from_str_radix(&hex[4..6], 16).ok()?;
+ return Some(Rgba {
+ r: f64::from(r),
+ g: f64::from(g),
+ b: f64::from(b),
+ a: 1.0,
+ });
+ }
+ 8 => {
+ let r = u8::from_str_radix(&hex[0..2], 16).ok()?;
+ let g = u8::from_str_radix(&hex[2..4], 16).ok()?;
+ let b = u8::from_str_radix(&hex[4..6], 16).ok()?;
+ let a = u8::from_str_radix(&hex[6..8], 16).ok()?;
+ return Some(Rgba {
+ r: f64::from(r),
+ g: f64::from(g),
+ b: f64::from(b),
+ a: f64::from(a) / 255.0,
+ });
+ }
+ _ => return None,
+ }
+ }
+ if let Some(body) = trimmed
+ .strip_prefix("rgb(")
+ .and_then(|rest| rest.strip_suffix(')'))
+ {
+ let parts: Vec<&str> = body.split(',').map(str::trim).collect();
+ if parts.len() == 3 && parts.iter().all(|part| is_digits_and_dots(part)) {
+ let r = parts[0].parse::<f64>().ok()?;
+ let g = parts[1].parse::<f64>().ok()?;
+ let b = parts[2].parse::<f64>().ok()?;
+ return Some(Rgba { r, g, b, a: 1.0 });
+ }
+ return None;
+ }
+ if let Some(body) = trimmed
+ .strip_prefix("rgba(")
+ .and_then(|rest| rest.strip_suffix(')'))
+ {
+ let parts: Vec<&str> = body.split(',').map(str::trim).collect();
+ if parts.len() == 4 && parts.iter().all(|part| is_digits_and_dots(part)) {
+ let r = parts[0].parse::<f64>().ok()?;
+ let g = parts[1].parse::<f64>().ok()?;
+ let b = parts[2].parse::<f64>().ok()?;
+ let a = parts[3].parse::<f64>().ok()?;
+ return Some(Rgba { r, g, b, a });
+ }
+ return None;
+ }
+ None
+}
diff --git a/rust/crates/graphics/src/definitions.rs b/rust/crates/graphics/src/definitions.rs
new file mode 100644
index 00000000..d14f465f
--- /dev/null
+++ b/rust/crates/graphics/src/definitions.rs
@@ -0,0 +1,531 @@
+use scene::params::ParamValues;
+use tiny_skia::{Color, FillRule, Paint, Path, PathBuilder, Point, Rect, Shader, Transform};
+
+use crate::color::parse_css_color;
+use crate::error::GraphicsError;
+use crate::params::{ParamDefinition, ParamGroup, SelectOption};
+use crate::stroke::apply_aligned_stroke;
+use crate::types::{GraphicDefinition, param_number, param_string};
+
+#[derive(Debug, Clone, Copy, PartialEq, Eq)]
+pub enum StrokeAlign {
+ Inside,
+ Center,
+ Outside,
+}
+
+impl StrokeAlign {
+ fn parse(value: &str) -> StrokeAlign {
+ match value {
+ "inside" => StrokeAlign::Inside,
+ "outside" => StrokeAlign::Outside,
+ _ => StrokeAlign::Center,
+ }
+ }
+}
+
+pub const STROKE_ALIGN_PARAM: ParamDefinition = ParamDefinition::Select {
+ key: "strokeAlign",
+ label: "Stroke align",
+ group: Some(ParamGroup::Stroke),
+ default: "center",
+ options: &[
+ SelectOption { value: "inside", label: "Inside" },
+ SelectOption { value: "center", label: "Center" },
+ SelectOption { value: "outside", label: "Outside" },
+ ],
+};
+
+const FILL_PARAM: ParamDefinition = ParamDefinition::Color {
+ key: "fill",
+ label: "Fill",
+ group: None,
+ default: "#ffffff",
+};
+
+const STROKE_PARAM: ParamDefinition = ParamDefinition::Color {
+ key: "stroke",
+ label: "Color",
+ group: Some(ParamGroup::Stroke),
+ default: "#000000",
+};
+
+const STROKE_WIDTH_PARAM: ParamDefinition = ParamDefinition::Number {
+ key: "strokeWidth",
+ label: "Width",
+ group: Some(ParamGroup::Stroke),
+ default: 0.0,
+ min: 0.0,
+ max: Some(64.0),
+ step: 1.0,
+ short_label: Some("W"),
+};
+
+const CORNER_RADIUS_PARAM: ParamDefinition = ParamDefinition::Number {
+ key: "cornerRadius",
+ label: "Corner radius",
+ group: None,
+ default: 0.0,
+ min: 0.0,
+ max: Some(50.0),
+ step: 1.0,
+ short_label: Some("R"),
+};
+
+struct ShapeParams {
+ fill: Color,
+ stroke: Color,
+ stroke_width: f64,
+ stroke_align: StrokeAlign,
+ inset: f64,
+}
+
+fn resolve_shape_params(params: &ParamValues) -> Result<ShapeParams, GraphicsError> {
+ let fill_raw = param_string(params, "fill", "#ffffff");
+ let stroke_raw = param_string(params, "stroke", "#000000");
+ let fill = parse_css_color(&fill_raw)
+ .ok_or_else(|| GraphicsError::InvalidColor(fill_raw.clone()))?;
+ let stroke = parse_css_color(&stroke_raw)
+ .ok_or_else(|| GraphicsError::InvalidColor(stroke_raw.clone()))?;
+ let stroke_width = param_number(params, "strokeWidth", 0.0).max(0.0);
+ let stroke_align = StrokeAlign::parse(&param_string(params, "strokeAlign", "center"));
+ let inset = if stroke_align == StrokeAlign::Center {
+ stroke_width / 2.0
+ } else {
+ 0.0
+ };
+ Ok(ShapeParams {
+ fill: Color::from_rgba(
+ fill.r as f32,
+ fill.g as f32,
+ fill.b as f32,
+ fill.a as f32,
+ )
+ .unwrap_or(Color::TRANSPARENT),
+ stroke: Color::from_rgba(
+ stroke.r as f32,
+ stroke.g as f32,
+ stroke.b as f32,
+ stroke.a as f32,
+ )
+ .unwrap_or(Color::TRANSPARENT),
+ stroke_width,
+ stroke_align,
+ inset,
+ })
+}
+
+fn fill_and_stroke(
+ pixmap: &mut tiny_skia::Pixmap,
+ path: &Path,
+ params: &ShapeParams,
+) {
+ let paint = Paint {
+ shader: Shader::SolidColor(params.fill),
+ anti_alias: true,
+ ..Paint::default()
+ };
+ pixmap.fill_path(path, &paint, FillRule::Winding, Transform::default(), None);
+ if params.stroke_width > 0.0 {
+ apply_aligned_stroke(
+ pixmap,
+ path,
+ params.stroke_width,
+ params.stroke_align,
+ params.stroke,
+ );
+ }
+}
+
+fn push_round_rect(builder: &mut PathBuilder, x: f64, y: f64, w: f64, h: f64, radius: f64) {
+ if radius <= 0.0 {
+ if let Some(rect) = Rect::from_xywh(x as f32, y as f32, w as f32, h as f32) {
+ builder.push_rect(rect);
+ }
+ return;
+ }
+ let radius = radius.min(w / 2.0).min(h / 2.0);
+ const KAPPA: f64 = 0.552_284_749_830_793_6;
+ let c = radius * KAPPA;
+ let (x, y, w, h, r) = (x as f32, y as f32, w as f32, h as f32, radius as f32);
+ let c = c as f32;
+ builder.move_to(x + r, y);
+ builder.line_to(x + w - r, y);
+ builder.cubic_to(x + w - r + c, y, x + w, y + r - c, x + w, y + r);
+ builder.line_to(x + w, y + h - r);
+ builder.cubic_to(x + w, y + h - r + c, x + w - r + c, y + h, x + w - r, y + h);
+ builder.line_to(x + r, y + h);
+ builder.cubic_to(x + r - c, y + h, x, y + h - r + c, x, y + h - r);
+ builder.line_to(x, y + r);
+ builder.cubic_to(x, y + r - c, x + r - c, y, x + r, y);
+ builder.close();
+}
+
+fn arc_to(
+ builder: &mut PathBuilder,
+ x1: f64,
+ y1: f64,
+ x2: f64,
+ y2: f64,
+ radius: f64,
+) {
+ let Some(last) = builder.last_point() else {
+ builder.move_to(x1 as f32, y1 as f32);
+ return;
+ };
+ let (x0, y0) = (f64::from(last.x), f64::from(last.y));
+ let coincident = |ax: f64, ay: f64, bx: f64, by: f64| ax == bx && ay == by;
+ if radius <= 0.0
+ || coincident(x0, y0, x1, y1)
+ || coincident(x1, y1, x2, y2)
+ {
+ builder.line_to(x1 as f32, y1 as f32);
+ return;
+ }
+ let v1 = (x0 - x1, y0 - y1);
+ let v2 = (x2 - x1, y2 - y1);
+ let cross = v1.0 * v2.1 - v1.1 * v2.0;
+ if cross == 0.0 {
+ builder.line_to(x1 as f32, y1 as f32);
+ return;
+ }
+ let len1 = v1.0.hypot(v1.1);
+ let len2 = v2.0.hypot(v2.1);
+ let u1 = (v1.0 / len1, v1.1 / len1);
+ let u2 = (v2.0 / len2, v2.1 / len2);
+ let cos_angle = (u1.0 * u2.0 + u1.1 * u2.1).clamp(-1.0, 1.0);
+ let angle = cos_angle.acos();
+ if angle == 0.0 || angle == std::f64::consts::PI {
+ builder.line_to(x1 as f32, y1 as f32);
+ return;
+ }
+ let tangent = radius / (angle / 2.0).tan();
+ let t1 = (x1 + u1.0 * tangent, y1 + u1.1 * tangent);
+ let t2 = (x1 + u2.0 * tangent, y1 + u2.1 * tangent);
+ builder.line_to(t1.0 as f32, t1.1 as f32);
+ let bisector = (u1.0 + u2.0, u1.1 + u2.1);
+ let bisector_len = bisector.0.hypot(bisector.1);
+ if bisector_len == 0.0 {
+ return;
+ }
+ let center_dist = radius / (angle / 2.0).sin();
+ let center = (
+ x1 + bisector.0 / bisector_len * center_dist,
+ y1 + bisector.1 / bisector_len * center_dist,
+ );
+ let start_angle = (t1.1 - center.1).atan2(t1.0 - center.0);
+ let mut end_angle = (t2.1 - center.1).atan2(t2.0 - center.0);
+ let sweep_clockwise = cross < 0.0;
+ if sweep_clockwise {
+ while end_angle <= start_angle {
+ end_angle += std::f64::consts::TAU;
+ }
+ } else {
+ while end_angle >= start_angle {
+ end_angle -= std::f64::consts::TAU;
+ }
+ }
+ let sweep = end_angle - start_angle;
+ let segments = ((sweep.abs() / (std::f64::consts::FRAC_PI_2)).ceil() as u32).max(1);
+ let step = sweep / f64::from(segments);
+ let kappa = 4.0 / 3.0 * (step / 4.0).tan();
+ let mut current = start_angle;
+ for _ in 0..segments {
+ let next = current + step;
+ let p0 = (
+ center.0 + radius * current.cos(),
+ center.1 + radius * current.sin(),
+ );
+ let p3 = (center.0 + radius * next.cos(), center.1 + radius * next.sin());
+ let d0 = (-current.sin(), current.cos());
+ let d1 = (-next.sin(), next.cos());
+ let p1 = (p0.0 + kappa * radius * d0.0, p0.1 + kappa * radius * d0.1);
+ let p2 = (p3.0 - kappa * radius * d1.0, p3.1 - kappa * radius * d1.1);
+ builder.cubic_to(
+ p1.0 as f32,
+ p1.1 as f32,
+ p2.0 as f32,
+ p2.1 as f32,
+ p3.0 as f32,
+ p3.1 as f32,
+ );
+ current = next;
+ }
+}
+
+pub static RECTANGLE_GRAPHIC_DEFINITION: GraphicDefinition = GraphicDefinition {
+ id: "rectangle",
+ name: "Rectangle",
+ keywords: &["rectangle", "square", "box"],
+ params: &[
+ FILL_PARAM,
+ STROKE_PARAM,
+ STROKE_WIDTH_PARAM,
+ STROKE_ALIGN_PARAM,
+ CORNER_RADIUS_PARAM,
+ ],
+ render: render_rectangle,
+};
+
+fn render_rectangle(context: &mut crate::types::GraphicRenderContext) -> Result<(), GraphicsError> {
+ let params = resolve_shape_params(context.params)?;
+ let width = f64::from(context.width);
+ let height = f64::from(context.height);
+ let draw_width = (width - params.inset * 2.0).max(1.0);
+ let draw_height = (height - params.inset * 2.0).max(1.0);
+ let radius_percent = param_number(context.params, "cornerRadius", 0.0).max(0.0);
+ let radius = (draw_width.min(draw_height) / 2.0) * radius_percent.min(50.0) / 50.0;
+ let mut builder = PathBuilder::new();
+ push_round_rect(&mut builder, params.inset, params.inset, draw_width, draw_height, radius);
+ let Some(path) = builder.finish() else {
+ return Ok(());
+ };
+ fill_and_stroke(context.pixmap, &path, &params);
+ Ok(())
+}
+
+pub static ELLIPSE_GRAPHIC_DEFINITION: GraphicDefinition = GraphicDefinition {
+ id: "ellipse",
+ name: "Ellipse",
+ keywords: &["ellipse", "circle", "oval"],
+ params: &[
+ FILL_PARAM,
+ STROKE_PARAM,
+ STROKE_WIDTH_PARAM,
+ STROKE_ALIGN_PARAM,
+ ],
+ render: render_ellipse,
+};
+
+fn render_ellipse(context: &mut crate::types::GraphicRenderContext) -> Result<(), GraphicsError> {
+ let params = resolve_shape_params(context.params)?;
+ let width = f64::from(context.width);
+ let height = f64::from(context.height);
+ let center_x = width / 2.0;
+ let center_y = height / 2.0;
+ let radius_x = (width / 2.0 - params.inset).max(1.0);
+ let radius_y = (height / 2.0 - params.inset).max(1.0);
+ let mut builder = PathBuilder::new();
+ if let Some(rect) = Rect::from_xywh(
+ (center_x - radius_x) as f32,
+ (center_y - radius_y) as f32,
+ (radius_x * 2.0) as f32,
+ (radius_y * 2.0) as f32,
+ ) {
+ builder.push_oval(rect);
+ }
+ let Some(path) = builder.finish() else {
+ return Ok(());
+ };
+ fill_and_stroke(context.pixmap, &path, &params);
+ Ok(())
+}
+
+const SIDES_PARAM: ParamDefinition = ParamDefinition::Number {
+ key: "sides",
+ label: "Sides",
+ group: None,
+ default: 5.0,
+ min: 3.0,
+ max: Some(12.0),
+ step: 1.0,
+ short_label: Some("S"),
+};
+
+pub static POLYGON_GRAPHIC_DEFINITION: GraphicDefinition = GraphicDefinition {
+ id: "polygon",
+ name: "Polygon",
+ keywords: &["polygon", "triangle", "pentagon", "hexagon", "diamond"],
+ params: &[
+ FILL_PARAM,
+ STROKE_PARAM,
+ STROKE_WIDTH_PARAM,
+ STROKE_ALIGN_PARAM,
+ SIDES_PARAM,
+ CORNER_RADIUS_PARAM,
+ ],
+ render: render_polygon,
+};
+
+fn build_polygon_vertices(
+ center_x: f64,
+ center_y: f64,
+ radius: f64,
+ sides: usize,
+) -> Vec<Point> {
+ (0..sides)
+ .map(|index| {
+ let angle = -std::f64::consts::FRAC_PI_2
+ + (index as f64 * std::f64::consts::TAU) / sides as f64;
+ Point::from_xy(
+ (center_x + angle.cos() * radius) as f32,
+ (center_y + angle.sin() * radius) as f32,
+ )
+ })
+ .collect()
+}
+
+fn trace_rounded_polygon_path(
+ builder: &mut PathBuilder,
+ vertices: &[Point],
+ radius: f64,
+) {
+ if vertices.len() < 3 {
+ return;
+ }
+ if radius <= 0.0 {
+ builder.move_to(vertices[0].x, vertices[0].y);
+ for vertex in &vertices[1..] {
+ builder.line_to(vertex.x, vertex.y);
+ }
+ builder.close();
+ return;
+ }
+ let count = vertices.len();
+ for index in 0..count {
+ let previous = vertices[(index + count - 1) % count];
+ let current = vertices[index];
+ let next = vertices[(index + 1) % count];
+ let to_previous = normalize(
+ f64::from(previous.x - current.x),
+ f64::from(previous.y - current.y),
+ );
+ let to_next = normalize(f64::from(next.x - current.x), f64::from(next.y - current.y));
+ let angle = (to_previous.0 * to_next.0 + to_previous.1 * to_next.1)
+ .clamp(-1.0, 1.0)
+ .acos();
+ let dist_prev = (f64::from(previous.x - current.x)).hypot(f64::from(previous.y - current.y));
+ let dist_next = (f64::from(next.x - current.x)).hypot(f64::from(next.y - current.y));
+ let max_offset = dist_prev.min(dist_next) / 2.0;
+ let tangent_offset = (radius / (angle / 2.0).tan()).min(max_offset);
+ let start = (
+ f64::from(current.x) + to_previous.0 * tangent_offset,
+ f64::from(current.y) + to_previous.1 * tangent_offset,
+ );
+ let end = (
+ f64::from(current.x) + to_next.0 * tangent_offset,
+ f64::from(current.y) + to_next.1 * tangent_offset,
+ );
+ if index == 0 {
+ builder.move_to(start.0 as f32, start.1 as f32);
+ } else {
+ builder.line_to(start.0 as f32, start.1 as f32);
+ }
+ arc_to(
+ builder,
+ f64::from(current.x),
+ f64::from(current.y),
+ end.0,
+ end.1,
+ radius.min(max_offset),
+ );
+ }
+ builder.close();
+}
+
+fn normalize(x: f64, y: f64) -> (f64, f64) {
+ let length = x.hypot(y);
+ if length == 0.0 {
+ return (0.0, 0.0);
+ }
+ (x / length, y / length)
+}
+
+fn render_polygon(context: &mut crate::types::GraphicRenderContext) -> Result<(), GraphicsError> {
+ let params = resolve_shape_params(context.params)?;
+ let width = f64::from(context.width);
+ let height = f64::from(context.height);
+ let sides = (param_number(context.params, "sides", 5.0).round() as i64).clamp(3, 12) as usize;
+ let radius = (width.min(height) / 2.0 - params.inset).max(1.0);
+ let max_corner_radius = radius * (std::f64::consts::PI / sides as f64).sin();
+ let corner_radius_percent = param_number(context.params, "cornerRadius", 0.0).max(0.0);
+ let corner_radius = max_corner_radius * corner_radius_percent.min(50.0) / 50.0;
+ let vertices = build_polygon_vertices(width / 2.0, height / 2.0, radius, sides);
+ let mut builder = PathBuilder::new();
+ trace_rounded_polygon_path(&mut builder, &vertices, corner_radius);
+ let Some(path) = builder.finish() else {
+ return Ok(());
+ };
+ fill_and_stroke(context.pixmap, &path, &params);
+ Ok(())
+}
+
+const POINTS_PARAM: ParamDefinition = ParamDefinition::Number {
+ key: "points",
+ label: "Points",
+ group: None,
+ default: 5.0,
+ min: 3.0,
+ max: Some(12.0),
+ step: 1.0,
+ short_label: Some("P"),
+};
+
+const DEPTH_PARAM: ParamDefinition = ParamDefinition::Number {
+ key: "depth",
+ label: "Depth",
+ group: None,
+ default: 45.0,
+ min: 1.0,
+ max: Some(99.0),
+ step: 1.0,
+ short_label: Some("D"),
+};
+
+pub static STAR_GRAPHIC_DEFINITION: GraphicDefinition = GraphicDefinition {
+ id: "star",
+ name: "Star",
+ keywords: &["star", "sparkle", "burst"],
+ params: &[
+ FILL_PARAM,
+ STROKE_PARAM,
+ STROKE_WIDTH_PARAM,
+ STROKE_ALIGN_PARAM,
+ POINTS_PARAM,
+ DEPTH_PARAM,
+ ],
+ render: render_star,
+};
+
+fn render_star(context: &mut crate::types::GraphicRenderContext) -> Result<(), GraphicsError> {
+ let params = resolve_shape_params(context.params)?;
+ let width = f64::from(context.width);
+ let height = f64::from(context.height);
+ let points = (param_number(context.params, "points", 5.0).round() as i64).clamp(3, 12) as usize;
+ let depth = (param_number(context.params, "depth", 45.0).clamp(1.0, 99.0)) / 100.0;
+ let outer_radius = (width.min(height) / 2.0 - params.inset).max(1.0);
+ let inner_radius = outer_radius * depth;
+ let center_x = width / 2.0;
+ let center_y = height / 2.0;
+ let mut builder = PathBuilder::new();
+ for index in 0..points * 2 {
+ let radius = if index % 2 == 0 {
+ outer_radius
+ } else {
+ inner_radius
+ };
+ let angle = -std::f64::consts::FRAC_PI_2 + (index as f64 * std::f64::consts::PI) / points as f64;
+ let x = center_x + angle.cos() * radius;
+ let y = center_y + angle.sin() * radius;
+ if index == 0 {
+ builder.move_to(x as f32, y as f32);
+ } else {
+ builder.line_to(x as f32, y as f32);
+ }
+ }
+ builder.close();
+ let Some(path) = builder.finish() else {
+ return Ok(());
+ };
+ fill_and_stroke(context.pixmap, &path, &params);
+ Ok(())
+}
+
+pub fn default_graphic_definitions() -> Vec<&'static GraphicDefinition> {
+ vec![
+ &RECTANGLE_GRAPHIC_DEFINITION,
+ &ELLIPSE_GRAPHIC_DEFINITION,
+ &POLYGON_GRAPHIC_DEFINITION,
+ &STAR_GRAPHIC_DEFINITION,
+ ]
+}
diff --git a/rust/crates/graphics/src/error.rs b/rust/crates/graphics/src/error.rs
new file mode 100644
index 00000000..4404520f
--- /dev/null
+++ b/rust/crates/graphics/src/error.rs
@@ -0,0 +1,13 @@
+use thiserror::Error;
+
+use crate::gradient::GradientParseError;
+
+#[derive(Debug, Error, PartialEq)]
+pub enum GraphicsError {
+ #[error("Unknown graphic: {0}")]
+ UnknownGraphic(String),
+ #[error("Invalid color: {0}")]
+ InvalidColor(String),
+ #[error(transparent)]
+ GradientParse(#[from] GradientParseError),
+}
diff --git a/rust/crates/graphics/src/gradient/ast.rs b/rust/crates/graphics/src/gradient/ast.rs
new file mode 100644
index 00000000..d3336fea
--- /dev/null
+++ b/rust/crates/graphics/src/gradient/ast.rs
@@ -0,0 +1,105 @@
+#[derive(Debug, Clone, Copy, PartialEq, Eq)]
+pub enum GradientKind {
+ Linear,
+ RepeatingLinear,
+ Radial,
+ RepeatingRadial,
+}
+
+impl GradientKind {
+ pub fn as_str(&self) -> &'static str {
+ match self {
+ GradientKind::Linear => "linear-gradient",
+ GradientKind::RepeatingLinear => "repeating-linear-gradient",
+ GradientKind::Radial => "radial-gradient",
+ GradientKind::RepeatingRadial => "repeating-radial-gradient",
+ }
+ }
+
+ pub fn is_linear(&self) -> bool {
+ matches!(self, GradientKind::Linear | GradientKind::RepeatingLinear)
+ }
+}
+
+#[derive(Debug, Clone, PartialEq)]
+pub enum LinearOrientation {
+ Directional(String),
+ Angular(String),
+}
+
+#[derive(Debug, Clone, PartialEq)]
+pub enum Distance {
+ Percent(String),
+ PositionKeyword(String),
+ Calc(String),
+ Px(String),
+ Em(String),
+}
+
+#[derive(Debug, Clone, PartialEq, Default)]
+pub struct Position {
+ pub x: Option<Distance>,
+ pub y: Option<Distance>,
+}
+
+#[derive(Debug, Clone, Copy, PartialEq, Eq)]
+pub enum ShapeKind {
+ Circle,
+ Ellipse,
+}
+
+#[derive(Debug, Clone, PartialEq)]
+pub enum ShapeStyle {
+ Distance(Distance),
+ ExtentKeyword(String),
+ Position(Position),
+}
+
+#[derive(Debug, Clone, PartialEq)]
+pub struct Shape {
+ pub value: ShapeKind,
+ pub style: Option<ShapeStyle>,
+ pub at: Option<Position>,
+}
+
+#[derive(Debug, Clone, PartialEq)]
+pub enum RadialOrientation {
+ Shape(Shape),
+ ExtentKeyword {
+ value: String,
+ at: Option<Position>,
+ },
+ DefaultRadial {
+ at: Position,
+ },
+}
+
+#[derive(Debug, Clone, PartialEq)]
+pub enum GradientOrientation {
+ Linear(LinearOrientation),
+ Radial(Vec<RadialOrientation>),
+}
+
+#[derive(Debug, Clone, PartialEq)]
+pub enum Color {
+ Hex(String),
+ Literal(String),
+ Rgb(Vec<String>),
+ Rgba(Vec<String>),
+ Hsl([String; 3]),
+ Hsla([String; 4]),
+ Var(String),
+}
+
+#[derive(Debug, Clone, PartialEq)]
+pub struct ColorStop {
+ pub color: Color,
+ pub length: Option<Distance>,
+}
+
+#[derive(Debug, Clone, PartialEq)]
+pub struct GradientAst {
+ pub kind: GradientKind,
+ pub orientation: Option<GradientOrientation>,
+ pub color_stops: Vec<ColorStop>,
+}
diff --git a/rust/crates/graphics/src/gradient/mod.rs b/rust/crates/graphics/src/gradient/mod.rs
new file mode 100644
index 00000000..ec597ad0
--- /dev/null
+++ b/rust/crates/graphics/src/gradient/mod.rs
@@ -0,0 +1,15 @@
+pub mod ast;
+pub mod parser;
+pub mod raster;
+pub mod stops;
+
+pub use ast::{
+ Color, ColorStop, Distance, GradientAst, GradientKind, GradientOrientation,
+ LinearOrientation, Position, RadialOrientation, Shape, ShapeKind, ShapeStyle,
+};
+pub use parser::{GradientParseError, parse_gradient};
+pub use raster::{
+ BackgroundLayer, draw_css_background, is_gradient_layer, parse_background_layers,
+ split_css_layers,
+};
+pub use stops::{NormalizedColorStop, color_to_string, normalize_color_stops};
diff --git a/rust/crates/graphics/src/gradient/parser.rs b/rust/crates/graphics/src/gradient/parser.rs
new file mode 100644
index 00000000..d19a05ae
--- /dev/null
+++ b/rust/crates/graphics/src/gradient/parser.rs
@@ -0,0 +1,614 @@
+use thiserror::Error;
+
+use super::ast::{
+ Color, ColorStop, Distance, GradientAst, GradientKind, GradientOrientation,
+ LinearOrientation, Position, RadialOrientation, Shape, ShapeKind, ShapeStyle,
+};
+
+#[derive(Debug, Clone, Error, PartialEq)]
+#[error("{input}: {message}")]
+pub struct GradientParseError {
+ pub input: String,
+ pub message: String,
+}
+
+type PResult<T> = Result<T, GradientParseError>;
+
+type TokenMatch = (usize, usize, usize);
+
+fn match_ci_prefix(s: &str, keyword: &str) -> Option<usize> {
+ if s.len() >= keyword.len() && s[..keyword.len()].eq_ignore_ascii_case(keyword) {
+ Some(keyword.len())
+ } else {
+ None
+ }
+}
+
+fn scan_number_str(s: &str) -> Option<usize> {
+ let bytes = s.as_bytes();
+ let mut index = 0;
+ while index < bytes.len() && bytes[index].is_ascii_digit() {
+ index += 1;
+ }
+ let int_len = index;
+ if index < bytes.len() && bytes[index] == b'.' {
+ index += 1;
+ let frac_start = index;
+ while index < bytes.len() && bytes[index].is_ascii_digit() {
+ index += 1;
+ }
+ if index > frac_start {
+ return Some(index);
+ }
+ if int_len > 0 {
+ return Some(frac_start);
+ }
+ return None;
+ }
+ if int_len > 0 { Some(int_len) } else { None }
+}
+
+fn scan_signed_number_unit(s: &str, unit: &str) -> Option<TokenMatch> {
+ let unsigned = s.strip_prefix('-').unwrap_or(s);
+ let sign_len = s.len() - unsigned.len();
+ let number_len = scan_number_str(unsigned)?;
+ if unsigned[number_len..].starts_with(unit) {
+ Some((sign_len + number_len + unit.len(), 0, sign_len + number_len))
+ } else {
+ None
+ }
+}
+
+fn whole(len: usize) -> Option<TokenMatch> {
+ Some((len, 0, len))
+}
+
+fn scan_percentage(s: &str) -> Option<TokenMatch> {
+ scan_signed_number_unit(s, "%")
+}
+
+fn scan_px(s: &str) -> Option<TokenMatch> {
+ scan_signed_number_unit(s, "px")
+}
+
+fn scan_em(s: &str) -> Option<TokenMatch> {
+ scan_signed_number_unit(s, "em")
+}
+
+fn scan_deg(s: &str) -> Option<TokenMatch> {
+ scan_signed_number_unit(s, "deg")
+}
+
+fn scan_rad(s: &str) -> Option<TokenMatch> {
+ scan_signed_number_unit(s, "rad")
+}
+
+fn scan_number_token(s: &str) -> Option<TokenMatch> {
+ scan_number_str(s).and_then(whole)
+}
+
+fn scan_hex_color(s: &str) -> Option<TokenMatch> {
+ let rest = s.strip_prefix('#')?;
+ let digits_len = rest.find(|c: char| !c.is_ascii_hexdigit()).unwrap_or(rest.len());
+ if digits_len == 0 {
+ return None;
+ }
+ Some((1 + digits_len, 1, 1 + digits_len))
+}
+
+fn scan_literal_color(s: &str) -> Option<TokenMatch> {
+ let len = s.find(|c: char| !c.is_ascii_alphabetic()).unwrap_or(s.len());
+ if len == 0 {
+ return None;
+ }
+ whole(len)
+}
+
+fn scan_variable_name(s: &str) -> Option<TokenMatch> {
+ let rest = s.strip_prefix("--")?;
+ let len = rest
+ .find(|c: char| {
+ !(c.is_ascii_alphanumeric() || c == '-' || c == ',' || c == '#' || c.is_whitespace())
+ })
+ .unwrap_or(rest.len());
+ if len == 0 {
+ return None;
+ }
+ whole(2 + len)
+}
+
+fn scan_position_keyword(s: &str) -> Option<TokenMatch> {
+ for keyword in ["left", "center", "right", "top", "bottom"] {
+ if let Some(len) = match_ci_prefix(s, keyword) {
+ return whole(len);
+ }
+ }
+ None
+}
+
+fn scan_extent_keyword(s: &str) -> Option<TokenMatch> {
+ for keyword in [
+ "closest-side",
+ "closest-corner",
+ "farthest-side",
+ "farthest-corner",
+ "contain",
+ "cover",
+ ] {
+ if s.starts_with(keyword) {
+ return whole(keyword.len());
+ }
+ }
+ None
+}
+
+fn scan_side_or_corner(s: &str) -> Option<TokenMatch> {
+ match_ci_prefix(s, "to ")?;
+ let rest = &s[3..];
+ let pairs: [(&str, &[&str]); 4] = [
+ ("left", &["top", "bottom"]),
+ ("right", &["top", "bottom"]),
+ ("top", &["left", "right"]),
+ ("bottom", &["left", "right"]),
+ ];
+ for (first, seconds) in pairs {
+ if let Some(first_len) = match_ci_prefix(rest, first) {
+ let after_first = &rest[first_len..];
+ for second in seconds {
+ let with_space = format!(" {second}");
+ if let Some(second_len) = match_ci_prefix(after_first, &with_space) {
+ let end = 3 + first_len + second_len;
+ return Some((end, 3, end));
+ }
+ }
+ }
+ }
+ for keyword in ["left", "right", "top", "bottom"] {
+ if let Some(len) = match_ci_prefix(rest, keyword) {
+ let end = 3 + len;
+ return Some((end, 3, end));
+ }
+ }
+ None
+}
+
+fn gradient_prefix(s: &str, name: &str) -> Option<TokenMatch> {
+ let mut offset = 0;
+ if s.starts_with('-') {
+ for vendor in ["webkit", "o", "ms", "moz"] {
+ let prefix = format!("-{vendor}-");
+ if let Some(len) = match_ci_prefix(s, &prefix) {
+ offset = len;
+ break;
+ }
+ }
+ if offset == 0 {
+ return None;
+ }
+ }
+ let name_len = match_ci_prefix(&s[offset..], name)?;
+ whole(offset + name_len)
+}
+
+struct Parser<'a> {
+ rest: &'a str,
+}
+
+impl<'a> Parser<'a> {
+ fn error(&self, message: &str) -> GradientParseError {
+ GradientParseError {
+ input: self.rest.to_string(),
+ message: message.to_string(),
+ }
+ }
+
+ fn skip_whitespace(&mut self) {
+ self.rest = self.rest.trim_start();
+ }
+
+ fn consume(&mut self, size: usize) {
+ self.rest = &self.rest[size..];
+ }
+
+ fn scan_token(&mut self, pattern: impl Fn(&str) -> Option<TokenMatch>) -> Option<String> {
+ self.skip_whitespace();
+ let (total, capture_start, capture_end) = pattern(self.rest)?;
+ let capture = self.rest[capture_start..capture_end].to_string();
+ self.consume(total);
+ Some(capture)
+ }
+
+ fn scan_char(&mut self, target: u8) -> bool {
+ self.skip_whitespace();
+ if self.rest.as_bytes().first() == Some(&target) {
+ self.consume(1);
+ return true;
+ }
+ false
+ }
+
+ fn scan_comma(&mut self) -> bool {
+ self.scan_char(b',')
+ }
+
+ fn match_call<T>(
+ &mut self,
+ pattern: impl Fn(&str) -> Option<TokenMatch>,
+ callback: impl FnOnce(&mut Self) -> PResult<T>,
+ ) -> PResult<Option<T>> {
+ self.skip_whitespace();
+ let Some((total, _, _)) = pattern(self.rest) else {
+ return Ok(None);
+ };
+ self.consume(total);
+ if !self.scan_char(b'(') {
+ return Err(self.error("Missing ("));
+ }
+ let result = callback(self)?;
+ if !self.scan_char(b')') {
+ return Err(self.error("Missing )"));
+ }
+ Ok(Some(result))
+ }
+
+ fn match_listing<T>(
+ &mut self,
+ matcher: fn(&mut Self) -> PResult<Option<T>>,
+ ) -> PResult<Vec<T>> {
+ let mut result = Vec::new();
+ let Some(first) = matcher(self)? else {
+ return Ok(result);
+ };
+ result.push(first);
+ while self.scan_comma() {
+ let next = matcher(self)?.ok_or_else(|| self.error("One extra comma"))?;
+ result.push(next);
+ }
+ Ok(result)
+ }
+
+ fn match_definition(&mut self) -> PResult<Option<GradientAst>> {
+ for (kind, name) in [
+ (GradientKind::Linear, "linear-gradient"),
+ (GradientKind::RepeatingLinear, "repeating-linear-gradient"),
+ (GradientKind::Radial, "radial-gradient"),
+ (GradientKind::RepeatingRadial, "repeating-radial-gradient"),
+ ] {
+ if let Some(ast) = self.match_gradient(kind, name)? {
+ return Ok(Some(ast));
+ }
+ }
+ Ok(None)
+ }
+
+ fn match_gradient(
+ &mut self,
+ kind: GradientKind,
+ name: &'static str,
+ ) -> PResult<Option<GradientAst>> {
+ self.match_call(
+ |s| gradient_prefix(s, name),
+ |parser| {
+ let orientation = if kind.is_linear() {
+ parser.match_linear_orientation()?
+ } else {
+ parser.match_list_radial_orientations()?
+ };
+ if orientation.is_some() && !parser.scan_comma() {
+ return Err(parser.error("Missing comma before color stops"));
+ }
+ Ok(GradientAst {
+ kind,
+ orientation,
+ color_stops: parser.match_listing(Self::match_color_stop)?,
+ })
+ },
+ )
+ }
+
+ fn match_linear_orientation(&mut self) -> PResult<Option<GradientOrientation>> {
+ if let Some(value) = self.scan_token(scan_side_or_corner) {
+ return Ok(Some(GradientOrientation::Linear(
+ LinearOrientation::Directional(value),
+ )));
+ }
+ if let Some(value) = self.scan_token(scan_position_keyword) {
+ return Ok(Some(GradientOrientation::Linear(
+ LinearOrientation::Directional(value),
+ )));
+ }
+ let angular = self
+ .scan_token(scan_deg)
+ .or_else(|| self.scan_token(scan_rad));
+ Ok(angular.map(|value| GradientOrientation::Linear(LinearOrientation::Angular(value))))
+ }
+
+ fn match_list_radial_orientations(&mut self) -> PResult<Option<GradientOrientation>> {
+ let Some(first) = self.match_radial_orientation()? else {
+ return Ok(None);
+ };
+ let mut orientations = vec![first];
+ let lookahead = self.rest;
+ if !self.scan_comma() {
+ return Ok(Some(GradientOrientation::Radial(orientations)));
+ }
+ let Some(next) = self.match_radial_orientation()? else {
+ self.rest = lookahead;
+ return Ok(Some(GradientOrientation::Radial(orientations)));
+ };
+ orientations.push(next);
+ Ok(Some(GradientOrientation::Radial(orientations)))
+ }
+
+ fn match_radial_orientation(&mut self) -> PResult<Option<RadialOrientation>> {
+ let shape = self.match_circle()?.or(self.match_ellipse()?);
+ if let Some(mut shape) = shape {
+ shape.at = self.match_at_position()?;
+ return Ok(Some(RadialOrientation::Shape(shape)));
+ }
+
+ if let Some(value) = self.scan_token(scan_extent_keyword) {
+ let at = self.match_at_position()?;
+ return Ok(Some(RadialOrientation::ExtentKeyword { value, at }));
+ }
+
+ if let Some(implicit) = self.match_implicit_ellipse()? {
+ return Ok(Some(RadialOrientation::Shape(implicit)));
+ }
+
+ if let Some(at) = self.match_at_position()? {
+ return Ok(Some(RadialOrientation::DefaultRadial { at }));
+ }
+
+ if let Some(at) = self.match_positioning()? {
+ return Ok(Some(RadialOrientation::DefaultRadial { at }));
+ }
+
+ Ok(None)
+ }
+
+ fn match_implicit_ellipse(&mut self) -> PResult<Option<Shape>> {
+ let lookahead = self.rest;
+ let Some(width) = self.match_distance()? else {
+ return Ok(None);
+ };
+ let Some(height) = self.match_distance()? else {
+ self.rest = lookahead;
+ return Ok(None);
+ };
+ let Some(at) = self.match_at_position()? else {
+ self.rest = lookahead;
+ return Ok(None);
+ };
+ Ok(Some(Shape {
+ value: ShapeKind::Ellipse,
+ style: Some(ShapeStyle::Position(Position {
+ x: Some(width),
+ y: Some(height),
+ })),
+ at: Some(at),
+ }))
+ }
+
+ fn match_circle(&mut self) -> PResult<Option<Shape>> {
+ if self.scan_token(|s| match_ci_prefix(s, "circle").and_then(whole)).is_none() {
+ return Ok(None);
+ }
+ let style = self
+ .match_length()
+ .map(ShapeStyle::Distance)
+ .or_else(|| self.scan_token(scan_extent_keyword).map(ShapeStyle::ExtentKeyword));
+ Ok(Some(Shape {
+ value: ShapeKind::Circle,
+ style,
+ at: None,
+ }))
+ }
+
+ fn match_ellipse(&mut self) -> PResult<Option<Shape>> {
+ if self.scan_token(|s| match_ci_prefix(s, "ellipse").and_then(whole)).is_none() {
+ return Ok(None);
+ }
+ let style = self
+ .match_positioning()?
+ .map(ShapeStyle::Position)
+ .or_else(|| self.match_distance().ok().flatten().map(ShapeStyle::Distance))
+ .or_else(|| self.scan_token(scan_extent_keyword).map(ShapeStyle::ExtentKeyword));
+ Ok(Some(Shape {
+ value: ShapeKind::Ellipse,
+ style,
+ at: None,
+ }))
+ }
+
+ fn match_at_position(&mut self) -> PResult<Option<Position>> {
+ if self.scan_token(|s| match_ci_prefix(s, "at").and_then(whole)).is_none() {
+ return Ok(None);
+ }
+ let Some(position) = self.match_positioning()? else {
+ return Err(self.error("Missing positioning value"));
+ };
+ Ok(Some(position))
+ }
+
+ fn match_positioning(&mut self) -> PResult<Option<Position>> {
+ let position = Position {
+ x: self.match_distance()?,
+ y: self.match_distance()?,
+ };
+ if position.x.is_none() && position.y.is_none() {
+ return Ok(None);
+ }
+ Ok(Some(position))
+ }
+
+ fn match_color_stop(&mut self) -> PResult<Option<ColorStop>> {
+ let color = self
+ .match_color()?
+ .ok_or_else(|| self.error("Expected color definition"))?;
+ let length = self.match_distance()?;
+ Ok(Some(ColorStop { color, length }))
+ }
+
+ fn match_color(&mut self) -> PResult<Option<Color>> {
+ if let Some(hex) = self.scan_token(scan_hex_color) {
+ return Ok(Some(Color::Hex(hex)));
+ }
+ if let Some(hsla) = self.match_hsla_color()? {
+ return Ok(Some(hsla));
+ }
+ if let Some(hsl) = self.match_hsl_color()? {
+ return Ok(Some(hsl));
+ }
+ if let Some(rgba) = self.match_rgba_color()? {
+ return Ok(Some(rgba));
+ }
+ if let Some(rgb) = self.match_rgb_color()? {
+ return Ok(Some(rgb));
+ }
+ if let Some(var) = self.match_var_color()? {
+ return Ok(Some(var));
+ }
+ Ok(self.scan_token(scan_literal_color).map(Color::Literal))
+ }
+
+ fn match_rgb_color(&mut self) -> PResult<Option<Color>> {
+ self.match_call(
+ |s| match_ci_prefix(s, "rgb").and_then(whole),
+ |parser| {
+ let values = parser.match_listing(|p| p.match_number().map(Some))?;
+ Ok(Color::Rgb(values))
+ },
+ )
+ }
+
+ fn match_rgba_color(&mut self) -> PResult<Option<Color>> {
+ self.match_call(
+ |s| match_ci_prefix(s, "rgba").and_then(whole),
+ |parser| {
+ let values = parser.match_listing(|p| p.match_number().map(Some))?;
+ Ok(Color::Rgba(values))
+ },
+ )
+ }
+
+ fn match_var_color(&mut self) -> PResult<Option<Color>> {
+ self.match_call(
+ |s| match_ci_prefix(s, "var").and_then(whole),
+ |parser| {
+ let name = parser
+ .scan_token(scan_variable_name)
+ .ok_or_else(|| parser.error("Expected CSS variable name"))?;
+ Ok(Color::Var(name))
+ },
+ )
+ }
+
+ fn match_hsl_color(&mut self) -> PResult<Option<Color>> {
+ self.match_call(
+ |s| match_ci_prefix(s, "hsl").and_then(whole),
+ |parser| {
+ if parser.scan_token(scan_percentage).is_some() {
+ return Err(parser.error(
+ "HSL hue value must be a number in degrees (0-360) or normalized (-360 to 360), not a percentage",
+ ));
+ }
+ let hue = parser.match_number()?;
+ parser.scan_comma();
+ let sat = parser.scan_token(scan_percentage);
+ parser.scan_comma();
+ let light = parser.scan_token(scan_percentage);
+ let sat = sat.ok_or_else(|| {
+ parser.error("Expected percentage value for saturation and lightness in HSL")
+ })?;
+ let light = light.ok_or_else(|| {
+ parser.error("Expected percentage value for saturation and lightness in HSL")
+ })?;
+ Ok(Color::Hsl([hue, sat, light]))
+ },
+ )
+ }
+
+ fn match_hsla_color(&mut self) -> PResult<Option<Color>> {
+ self.match_call(
+ |s| match_ci_prefix(s, "hsla").and_then(whole),
+ |parser| {
+ let hue = parser.match_number()?;
+ parser.scan_comma();
+ let sat = parser.scan_token(scan_percentage);
+ parser.scan_comma();
+ let light = parser.scan_token(scan_percentage);
+ parser.scan_comma();
+ let alpha = parser.match_number()?;
+ let sat = sat.ok_or_else(|| {
+ parser.error("Expected percentage value for saturation and lightness in HSLA")
+ })?;
+ let light = light.ok_or_else(|| {
+ parser.error("Expected percentage value for saturation and lightness in HSLA")
+ })?;
+ Ok(Color::Hsla([hue, sat, light, alpha]))
+ },
+ )
+ }
+
+ fn match_number(&mut self) -> PResult<String> {
+ self.scan_token(scan_number_token)
+ .ok_or_else(|| self.error("Expected number"))
+ }
+
+ fn match_distance(&mut self) -> PResult<Option<Distance>> {
+ if let Some(value) = self.scan_token(scan_percentage) {
+ return Ok(Some(Distance::Percent(value)));
+ }
+ if let Some(value) = self.scan_token(scan_position_keyword) {
+ return Ok(Some(Distance::PositionKeyword(value)));
+ }
+ if let Some(calc) = self.match_calc()? {
+ return Ok(Some(calc));
+ }
+ Ok(self.match_length())
+ }
+
+ fn match_calc(&mut self) -> PResult<Option<Distance>> {
+ self.match_call(
+ |s| match_ci_prefix(s, "calc").and_then(whole),
+ |parser| {
+ let mut open_parens = 1i32;
+ let mut index = 0usize;
+ let bytes = parser.rest.as_bytes();
+ while open_parens > 0 && index < bytes.len() {
+ match bytes[index] {
+ b'(' => open_parens += 1,
+ b')' => open_parens -= 1,
+ _ => {}
+ }
+ index += 1;
+ }
+ if open_parens > 0 {
+ return Err(parser.error("Missing closing parenthesis in calc() expression"));
+ }
+ let content = parser.rest[..index - 1].to_string();
+ parser.consume(index - 1);
+ Ok(Distance::Calc(content))
+ },
+ )
+ }
+
+ fn match_length(&mut self) -> Option<Distance> {
+ if let Some(value) = self.scan_token(scan_px) {
+ return Some(Distance::Px(value));
+ }
+ self.scan_token(scan_em).map(Distance::Em)
+ }
+}
+
+pub fn parse_gradient(code: &str) -> Result<Vec<GradientAst>, GradientParseError> {
+ let trimmed = code.trim();
+ let source = trimmed.strip_suffix(';').unwrap_or(trimmed);
+ let mut parser = Parser { rest: source };
+ let ast = parser.match_listing(Parser::match_definition)?;
+ if !parser.rest.is_empty() {
+ return Err(parser.error("Invalid input not EOF"));
+ }
+ Ok(ast)
+}
diff --git a/rust/crates/graphics/src/gradient/raster.rs b/rust/crates/graphics/src/gradient/raster.rs
new file mode 100644
index 00000000..12d30a23
--- /dev/null
+++ b/rust/crates/graphics/src/gradient/raster.rs
@@ -0,0 +1,549 @@
+use tiny_skia::{
+ Color as SkColor, GradientStop, LinearGradient, Paint, Pixmap, Point, RadialGradient, Rect,
+ Shader, SpreadMode, Transform,
+};
+
+use super::ast::{
+ Distance, GradientAst, GradientOrientation, LinearOrientation, Position, RadialOrientation,
+ ShapeKind, ShapeStyle,
+};
+use super::parser::parse_gradient;
+use super::stops::normalize_color_stops;
+use crate::color::{Rgba, parse_css_color};
+use crate::error::GraphicsError;
+
+#[derive(Debug, Clone, PartialEq)]
+pub enum BackgroundLayer {
+ Color(String),
+ Gradient(GradientAst),
+}
+
+pub fn is_gradient_layer(segment: &str) -> bool {
+ let names = [
+ "linear-gradient",
+ "repeating-linear-gradient",
+ "radial-gradient",
+ "repeating-radial-gradient",
+ ];
+ let mut offset = 0;
+ let mut rest = segment;
+ if rest.starts_with('-') {
+ for vendor in ["-webkit-", "-o-", "-ms-", "-moz-"] {
+ if rest.len() >= vendor.len() && rest[..vendor.len()].eq_ignore_ascii_case(vendor) {
+ offset = vendor.len();
+ break;
+ }
+ }
+ if offset == 0 {
+ return false;
+ }
+ rest = &rest[offset..];
+ }
+ names
+ .iter()
+ .any(|name| rest.len() >= name.len() && rest[..name.len()].eq_ignore_ascii_case(name))
+}
+
+pub fn split_css_layers(css: &str) -> Vec<String> {
+ let mut layers = Vec::new();
+ let mut current = String::new();
+ let mut depth = 0i32;
+ for ch in css.chars() {
+ if ch == '(' {
+ depth += 1;
+ }
+ if ch == ')' {
+ depth = depth.saturating_sub(1);
+ }
+ if ch == ',' && depth == 0 {
+ layers.push(current.trim().to_string());
+ current = String::new();
+ continue;
+ }
+ current.push(ch);
+ }
+ if !current.trim().is_empty() {
+ layers.push(current.trim().to_string());
+ }
+ layers
+}
+
+pub fn parse_background_layers(css: &str) -> Vec<BackgroundLayer> {
+ let mut layers = Vec::new();
+ for segment in split_css_layers(css) {
+ if segment.is_empty() {
+ continue;
+ }
+ if is_gradient_layer(&segment) {
+ match parse_gradient(segment.trim()) {
+ Ok(parsed) => {
+ for gradient in parsed {
+ layers.push(BackgroundLayer::Gradient(gradient));
+ }
+ continue;
+ }
+ Err(_) => {
+ layers.push(BackgroundLayer::Color(segment));
+ continue;
+ }
+ }
+ }
+ layers.push(BackgroundLayer::Color(segment));
+ }
+ layers
+}
+
+pub fn draw_css_background(pixmap: &mut Pixmap, css: &str) -> Result<(), GraphicsError> {
+ let width = f64::from(pixmap.width());
+ let height = f64::from(pixmap.height());
+ let layers = parse_background_layers(css);
+ for layer in layers.iter().rev() {
+ match layer {
+ BackgroundLayer::Color(value) => {
+ let color = parse_css_color(value)
+ .ok_or_else(|| GraphicsError::InvalidColor(value.clone()))?;
+ fill_solid(pixmap, color_to_sk(color));
+ }
+ BackgroundLayer::Gradient(gradient) => {
+ draw_gradient_layer(pixmap, width, height, gradient)?;
+ }
+ }
+ }
+ Ok(())
+}
+
+fn fill_solid(pixmap: &mut Pixmap, color: SkColor) {
+ let Some(rect) = Rect::from_xywh(0.0, 0.0, pixmap.width() as f32, pixmap.height() as f32)
+ else {
+ return;
+ };
+ let paint = Paint {
+ shader: Shader::SolidColor(color),
+ ..Paint::default()
+ };
+ pixmap.fill_rect(rect, &paint, Transform::default(), None);
+}
+
+fn color_to_sk(color: Rgba) -> SkColor {
+ SkColor::from_rgba(
+ color.r.clamp(0.0, 1.0) as f32,
+ color.g.clamp(0.0, 1.0) as f32,
+ color.b.clamp(0.0, 1.0) as f32,
+ color.a.clamp(0.0, 1.0) as f32,
+ )
+ .unwrap_or(SkColor::TRANSPARENT)
+}
+
+fn build_stops(
+ gradient: &GradientAst,
+ gradient_length: f64,
+) -> Result<Vec<GradientStop>, GraphicsError> {
+ let normalized = normalize_color_stops(&gradient.color_stops, gradient_length);
+ let mut stops = Vec::with_capacity(normalized.len());
+ for stop in normalized {
+ let color = parse_css_color(&stop.color)
+ .ok_or_else(|| GraphicsError::InvalidColor(stop.color.clone()))?;
+ stops.push(GradientStop::new(stop.offset as f32, color_to_sk(color)));
+ }
+ Ok(stops)
+}
+
+fn full_rect(width: f64, height: f64) -> Option<Rect> {
+ Rect::from_xywh(0.0, 0.0, width as f32, height as f32)
+}
+
+fn draw_gradient_layer(
+ pixmap: &mut Pixmap,
+ width: f64,
+ height: f64,
+ gradient: &GradientAst,
+) -> Result<(), GraphicsError> {
+ if gradient.kind.is_linear() {
+ draw_linear_gradient(pixmap, width, height, gradient)
+ } else {
+ draw_radial_gradient(pixmap, width, height, gradient)
+ }
+}
+
+struct LinearPoints {
+ x0: f64,
+ y0: f64,
+ x1: f64,
+ y1: f64,
+ length: f64,
+}
+
+fn resolve_linear_points(
+ width: f64,
+ height: f64,
+ orientation: Option<&GradientOrientation>,
+) -> LinearPoints {
+ let angle = resolve_linear_angle(orientation);
+ let radians = angle.to_radians();
+ let dx = radians.sin();
+ let dy = -radians.cos();
+ let center_x = width / 2.0;
+ let center_y = height / 2.0;
+ let half_length = ((width * dx).abs() + (height * dy).abs()) / 2.0;
+ let x0 = center_x - dx * half_length;
+ let y0 = center_y - dy * half_length;
+ let x1 = center_x + dx * half_length;
+ let y1 = center_y + dy * half_length;
+ let length = (x1 - x0).hypot(y1 - y0);
+ LinearPoints { x0, y0, x1, y1, length }
+}
+
+fn resolve_linear_angle(orientation: Option<&GradientOrientation>) -> f64 {
+ let Some(orientation) = orientation else {
+ return 180.0;
+ };
+ if let GradientOrientation::Linear(linear) = orientation {
+ match linear {
+ LinearOrientation::Angular(value) => {
+ return value.parse::<f64>().unwrap_or(180.0);
+ }
+ LinearOrientation::Directional(value) => {
+ return angle_from_directional(value);
+ }
+ }
+ }
+ 180.0
+}
+
+fn angle_from_directional(value: &str) -> f64 {
+ let normalized = value.to_lowercase().replace("to", "");
+ let mut dx: f64 = 0.0;
+ let mut dy: f64 = 0.0;
+ for part in normalized.split_whitespace() {
+ match part {
+ "left" => dx = -1.0,
+ "right" => dx = 1.0,
+ "top" => dy = -1.0,
+ "bottom" => dy = 1.0,
+ _ => {}
+ }
+ }
+ if dx == 0.0 && dy == 0.0 {
+ return 180.0;
+ }
+ let angle = dx.atan2(-dy).to_degrees();
+ (angle + 360.0) % 360.0
+}
+
+fn draw_linear_gradient(
+ pixmap: &mut Pixmap,
+ width: f64,
+ height: f64,
+ gradient: &GradientAst,
+) -> Result<(), GraphicsError> {
+ let points = resolve_linear_points(width, height, gradient.orientation.as_ref());
+ let stops = build_stops(gradient, points.length)?;
+ let (Some(shader), Some(rect)) = (
+ LinearGradient::new(
+ Point::from_xy(points.x0 as f32, points.y0 as f32),
+ Point::from_xy(points.x1 as f32, points.y1 as f32),
+ stops,
+ SpreadMode::Pad,
+ Transform::default(),
+ ),
+ full_rect(width, height),
+ ) else {
+ return Ok(());
+ };
+ let paint = Paint {
+ shader,
+ ..Paint::default()
+ };
+ pixmap.fill_rect(rect, &paint, Transform::default(), None);
+ Ok(())
+}
+
+struct RadialDimensions {
+ cx: f64,
+ cy: f64,
+ rx: f64,
+ ry: f64,
+}
+
+fn resolve_radial_dimensions(
+ width: f64,
+ height: f64,
+ orientation: Option<&GradientOrientation>,
+) -> RadialDimensions {
+ let center_fallback = (width / 2.0, height / 2.0);
+ let radial = match orientation {
+ Some(GradientOrientation::Radial(list)) => list.first(),
+ _ => None,
+ };
+
+ let Some(radial) = radial else {
+ let (rx, ry) = resolve_radial_extents(
+ width,
+ height,
+ center_fallback.0,
+ center_fallback.1,
+ ShapeKind::Ellipse,
+ "farthest-corner",
+ );
+ return RadialDimensions {
+ cx: center_fallback.0,
+ cy: center_fallback.1,
+ rx,
+ ry,
+ };
+ };
+
+ match radial {
+ RadialOrientation::Shape(shape) => {
+ let (cx, cy) = resolve_radial_center(width, height, shape.at.as_ref());
+ if let Some(ShapeStyle::Position(position)) = &shape.style {
+ let rx = position
+ .x
+ .as_ref()
+ .map(|d| resolve_ellipse_dimension(d, width))
+ .unwrap_or(width / 2.0);
+ let ry = position
+ .y
+ .as_ref()
+ .map(|d| resolve_ellipse_dimension(d, height))
+ .unwrap_or(height / 2.0);
+ return RadialDimensions { cx, cy, rx, ry };
+ }
+ if let Some(ShapeStyle::Distance(distance)) = &shape.style {
+ let resolved =
+ resolve_distance_in_pixels(distance, width.max(height)).unwrap_or(0.0);
+ return RadialDimensions {
+ cx,
+ cy,
+ rx: resolved,
+ ry: resolved,
+ };
+ }
+ let extent = match &shape.style {
+ Some(ShapeStyle::ExtentKeyword(value)) => value.as_str(),
+ _ => "farthest-corner",
+ };
+ let (rx, ry) = resolve_radial_extents(width, height, cx, cy, shape.value, extent);
+ RadialDimensions { cx, cy, rx, ry }
+ }
+ RadialOrientation::ExtentKeyword { value, at } => {
+ let (cx, cy) = resolve_radial_center(width, height, at.as_ref());
+ let (rx, ry) =
+ resolve_radial_extents(width, height, cx, cy, ShapeKind::Ellipse, value);
+ RadialDimensions { cx, cy, rx, ry }
+ }
+ RadialOrientation::DefaultRadial { at } => {
+ let (cx, cy) = resolve_radial_center(width, height, Some(at));
+ let (rx, ry) = resolve_radial_extents(
+ width,
+ height,
+ cx,
+ cy,
+ ShapeKind::Ellipse,
+ "farthest-corner",
+ );
+ RadialDimensions { cx, cy, rx, ry }
+ }
+ }
+}
+
+fn resolve_radial_center(width: f64, height: f64, position: Option<&Position>) -> (f64, f64) {
+ let Some(position) = position else {
+ return (width / 2.0, height / 2.0);
+ };
+ let normalized = normalize_position_keywords(position);
+ let cx = resolve_position_value(normalized.x.as_ref(), width, true);
+ let cy = resolve_position_value(normalized.y.as_ref(), height, false);
+ (cx, cy)
+}
+
+fn normalize_position_keywords(position: &Position) -> Position {
+ if let (Some(Distance::PositionKeyword(x_kw)), Some(Distance::PositionKeyword(y_kw))) =
+ (&position.x, &position.y)
+ {
+ let x_keyword = x_kw.to_lowercase();
+ let y_keyword = y_kw.to_lowercase();
+ let x_is_vertical = x_keyword == "top" || x_keyword == "bottom";
+ let y_is_horizontal = y_keyword == "left" || y_keyword == "right";
+ if x_is_vertical && y_is_horizontal {
+ return Position {
+ x: Some(Distance::PositionKeyword(y_keyword)),
+ y: Some(Distance::PositionKeyword(x_keyword)),
+ };
+ }
+ }
+ position.clone()
+}
+
+fn resolve_radial_extents(
+ width: f64,
+ height: f64,
+ cx: f64,
+ cy: f64,
+ shape: ShapeKind,
+ extent: &str,
+) -> (f64, f64) {
+ let left = cx;
+ let right = width - cx;
+ let top = cy;
+ let bottom = height - cy;
+
+ if shape == ShapeKind::Circle {
+ let distances = [
+ left.hypot(top),
+ right.hypot(top),
+ left.hypot(bottom),
+ right.hypot(bottom),
+ ];
+ return match extent {
+ "closest-side" => (left.min(right).min(top).min(bottom), 0.0),
+ "farthest-side" => (left.max(right).max(top).max(bottom), 0.0),
+ "closest-corner" => (distances.iter().cloned().fold(f64::INFINITY, f64::min), 0.0),
+ _ => (distances.iter().cloned().fold(f64::NEG_INFINITY, f64::max), 0.0),
+ };
+ }
+
+ match extent {
+ "closest-side" => (left.min(right), top.min(bottom)),
+ "farthest-side" => (left.max(right), top.max(bottom)),
+ _ => {
+ let corners = [(left, top), (right, top), (left, bottom), (right, bottom)];
+ let chosen = if extent == "closest-corner" {
+ corners
+ .iter()
+ .min_by(|a, b| a.0.hypot(a.1).total_cmp(&b.0.hypot(b.1)))
+ } else {
+ corners
+ .iter()
+ .max_by(|a, b| a.0.hypot(a.1).total_cmp(&b.0.hypot(b.1)))
+ };
+ let chosen = chosen.copied().unwrap_or((width / 2.0, height / 2.0));
+ (chosen.0.abs(), chosen.1.abs())
+ }
+ }
+}
+
+fn resolve_position_value(distance: Option<&Distance>, axis_size: f64, is_x: bool) -> f64 {
+ let Some(distance) = distance else {
+ return axis_size / 2.0;
+ };
+ match distance {
+ Distance::Percent(value) => value
+ .parse::<f64>()
+ .map(|v| (v / 100.0) * axis_size)
+ .unwrap_or(axis_size / 2.0),
+ Distance::PositionKeyword(value) => keyword_to_position(value, axis_size, is_x),
+ Distance::Px(value) => value.parse::<f64>().unwrap_or(axis_size / 2.0),
+ Distance::Em(value) => value
+ .parse::<f64>()
+ .map(|v| v * 16.0)
+ .unwrap_or(axis_size / 2.0),
+ _ => axis_size / 2.0,
+ }
+}
+
+fn keyword_to_position(value: &str, axis_size: f64, is_x: bool) -> f64 {
+ let keyword = value.to_lowercase();
+ if keyword == "center" {
+ return axis_size / 2.0;
+ }
+ if is_x {
+ if keyword == "left" {
+ return 0.0;
+ }
+ if keyword == "right" {
+ return axis_size;
+ }
+ } else {
+ if keyword == "top" {
+ return 0.0;
+ }
+ if keyword == "bottom" {
+ return axis_size;
+ }
+ }
+ axis_size / 2.0
+}
+
+fn resolve_distance_in_pixels(distance: &Distance, axis_size: f64) -> Option<f64> {
+ match distance {
+ Distance::Percent(value) => value.parse::<f64>().ok().map(|v| (v / 100.0) * axis_size),
+ Distance::Px(value) => value.parse::<f64>().ok(),
+ Distance::Em(value) => value.parse::<f64>().ok().map(|v| v * 16.0),
+ _ => None,
+ }
+}
+
+fn resolve_ellipse_dimension(distance: &Distance, axis_size: f64) -> f64 {
+ match distance {
+ Distance::Percent(value) => value
+ .parse::<f64>()
+ .map(|v| (v / 100.0) * axis_size)
+ .unwrap_or(axis_size / 2.0),
+ Distance::Px(value) => value.parse::<f64>().unwrap_or(axis_size / 2.0),
+ Distance::Em(value) => value
+ .parse::<f64>()
+ .map(|v| v * 16.0)
+ .unwrap_or(axis_size / 2.0),
+ _ => axis_size / 2.0,
+ }
+}
+
+fn draw_radial_gradient(
+ pixmap: &mut Pixmap,
+ width: f64,
+ height: f64,
+ gradient: &GradientAst,
+) -> Result<(), GraphicsError> {
+ let dims = resolve_radial_dimensions(width, height, gradient.orientation.as_ref());
+ let gradient_length = dims.rx.max(dims.ry);
+ let stops = build_stops(gradient, gradient_length)?;
+
+ if dims.rx == dims.ry || dims.ry == 0.0 {
+ let Some(shader) = RadialGradient::new(
+ Point::from_xy(dims.cx as f32, dims.cy as f32),
+ Point::from_xy(dims.cx as f32, dims.cy as f32),
+ dims.rx as f32,
+ stops,
+ SpreadMode::Pad,
+ Transform::default(),
+ ) else {
+ return Ok(());
+ };
+ if let Some(rect) = full_rect(width, height) {
+ let paint = Paint {
+ shader,
+ ..Paint::default()
+ };
+ pixmap.fill_rect(rect, &paint, Transform::default(), None);
+ }
+ return Ok(());
+ }
+
+ if dims.rx <= 0.0 {
+ return Ok(());
+ }
+ let scale_y = dims.ry / dims.rx;
+ if !scale_y.is_finite() || scale_y <= 0.0 {
+ return Ok(());
+ }
+ let transform = Transform::from_row(1.0, 0.0, 0.0, scale_y as f32, dims.cx as f32, dims.cy as f32);
+ let Some(shader) = RadialGradient::new(
+ Point::from_xy(0.0, 0.0),
+ Point::from_xy(0.0, 0.0),
+ dims.rx as f32,
+ stops,
+ SpreadMode::Pad,
+ transform,
+ ) else {
+ return Ok(());
+ };
+ if let Some(rect) = full_rect(width, height) {
+ let paint = Paint {
+ shader,
+ ..Paint::default()
+ };
+ pixmap.fill_rect(rect, &paint, Transform::default(), None);
+ }
+ Ok(())
+}
diff --git a/rust/crates/graphics/src/gradient/stops.rs b/rust/crates/graphics/src/gradient/stops.rs
new file mode 100644
index 00000000..e719ae96
--- /dev/null
+++ b/rust/crates/graphics/src/gradient/stops.rs
@@ -0,0 +1,151 @@
+use super::ast::{Color, ColorStop, Distance};
+use crate::color::{is_transparent, parse_color_to_rgb};
+
+#[derive(Debug, Clone, PartialEq)]
+pub struct NormalizedColorStop {
+ pub color: String,
+ pub offset: f64,
+}
+
+pub fn color_to_string(color: &Color) -> String {
+ match color {
+ Color::Hex(value) => format!("#{value}"),
+ Color::Literal(value) => value.clone(),
+ Color::Rgb(values) => format!("rgb({})", values.join(",")),
+ Color::Rgba(values) => format!("rgba({})", values.join(",")),
+ Color::Hsl(values) => format!("hsl({})", values.join(",")),
+ Color::Hsla(values) => format!("hsla({})", values.join(",")),
+ Color::Var(value) => format!("var({value})"),
+ }
+}
+
+fn clamp01(value: f64) -> f64 {
+ value.clamp(0.0, 1.0)
+}
+
+fn resolve_stop_offset(stop: &ColorStop, gradient_length: f64) -> Option<f64> {
+ match stop.length.as_ref()? {
+ Distance::Percent(value) => value.parse::<f64>().ok().map(|v| v / 100.0),
+ Distance::Px(value) => value.parse::<f64>().ok().map(|v| v / gradient_length),
+ Distance::Em(value) => value
+ .parse::<f64>()
+ .ok()
+ .map(|v| (v * 16.0) / gradient_length),
+ _ => None,
+ }
+}
+
+fn fix_transparent_stops(stops: &[(String, Option<f64>)]) -> Vec<(String, Option<f64>)> {
+ let mut result = stops.to_vec();
+ for i in 0..result.len() {
+ if !is_transparent(&result[i].0) {
+ continue;
+ }
+ let mut donor = None;
+ for j in (0..i).rev() {
+ if !is_transparent(&result[j].0) {
+ donor = parse_color_to_rgb(&result[j].0);
+ if donor.is_some() {
+ break;
+ }
+ }
+ }
+ if donor.is_none() {
+ for item in result.iter().skip(i + 1) {
+ if !is_transparent(&item.0) {
+ donor = parse_color_to_rgb(&item.0);
+ if donor.is_some() {
+ break;
+ }
+ }
+ }
+ }
+ if let Some(donor) = donor {
+ result[i].0 = format!("rgba({},{},{},0)", donor.r, donor.g, donor.b);
+ }
+ }
+ result
+}
+
+pub fn normalize_color_stops(
+ color_stops: &[ColorStop],
+ gradient_length: f64,
+) -> Vec<NormalizedColorStop> {
+ let mapped: Vec<(String, Option<f64>)> = color_stops
+ .iter()
+ .map(|stop| {
+ (
+ color_to_string(&stop.color),
+ resolve_stop_offset(stop, gradient_length),
+ )
+ })
+ .collect();
+
+ let fixed = fix_transparent_stops(&mapped);
+ let mut resolved = fixed;
+ let known_indices: Vec<usize> = resolved
+ .iter()
+ .enumerate()
+ .filter_map(|(index, stop)| stop.1.map(|_| index))
+ .collect();
+
+ if known_indices.is_empty() {
+ let step = if resolved.len() > 1 {
+ 1.0 / (resolved.len() - 1) as f64
+ } else {
+ 1.0
+ };
+ for (index, stop) in resolved.iter_mut().enumerate() {
+ stop.1 = Some(step * index as f64);
+ }
+ return resolved
+ .into_iter()
+ .map(|(color, offset)| NormalizedColorStop {
+ color,
+ offset: clamp01(offset.unwrap_or(0.0)),
+ })
+ .collect();
+ }
+
+ let first_known = known_indices[0];
+ if resolved[first_known].1.is_none() {
+ resolved[first_known].1 = Some(0.0);
+ }
+ for index in 0..first_known {
+ let next_offset = resolved[first_known].1.unwrap_or(0.0);
+ resolved[index].1 = Some((next_offset * index as f64) / first_known as f64);
+ }
+
+ for window in known_indices.windows(2) {
+ let start_index = window[0];
+ let end_index = window[1];
+ let start_offset = resolved[start_index].1.unwrap_or(0.0);
+ let end_offset = resolved[end_index].1.unwrap_or(start_offset);
+ let gap = end_index - start_index;
+ if gap <= 1 {
+ continue;
+ }
+ let step = (end_offset - start_offset) / gap as f64;
+ for index in 1..gap {
+ resolved[start_index + index].1 = Some(start_offset + step * index as f64);
+ }
+ }
+
+ let last_known = known_indices[known_indices.len() - 1];
+ let last_offset = resolved[last_known].1.unwrap_or(1.0);
+ if last_known < resolved.len() - 1 {
+ let gap = resolved.len() - 1 - last_known;
+ let step = (1.0 - last_offset) / gap as f64;
+ for index in 1..=gap {
+ resolved[last_known + index].1 = Some(last_offset + step * index as f64);
+ }
+ }
+
+ resolved
+ .into_iter()
+ .map(|(color, offset)| NormalizedColorStop {
+ color,
+ offset: clamp01(offset.unwrap_or(0.0)),
+ })
+ .collect()
+}
diff --git a/rust/crates/graphics/src/lib.rs b/rust/crates/graphics/src/lib.rs
index 8b137891..3261959b 100644
--- a/rust/crates/graphics/src/lib.rs
+++ b/rust/crates/graphics/src/lib.rs
@@ -1 +1,32 @@
+pub mod color;
+pub mod definitions;
+pub mod error;
+pub mod gradient;
+pub mod params;
+pub mod registry;
+pub mod stroke;
+pub mod types;
+pub use color::{Rgba, hsl_to_rgb, is_transparent, named_color, parse_color_to_rgb, parse_css_color};
+pub use definitions::{
+ ELLIPSE_GRAPHIC_DEFINITION, POLYGON_GRAPHIC_DEFINITION, RECTANGLE_GRAPHIC_DEFINITION,
+ STAR_GRAPHIC_DEFINITION, STROKE_ALIGN_PARAM, StrokeAlign, default_graphic_definitions,
+};
+pub use error::GraphicsError;
+pub use gradient::{
+ BackgroundLayer, Color, ColorStop, Distance, GradientAst, GradientKind, GradientOrientation,
+ GradientParseError, LinearOrientation, NormalizedColorStop, Position, RadialOrientation,
+ Shape, ShapeKind, ShapeStyle, color_to_string, draw_css_background, is_gradient_layer,
+ normalize_color_stops, parse_background_layers, parse_gradient, split_css_layers,
+};
+pub use params::{ParamDefinition, ParamGroup, SelectOption, build_default_param_values};
+pub use registry::{
+ GraphicsRegistry, build_default_graphic_instance, get_graphic_definition, graphics_registry,
+ render_graphic, render_graphic_rgba8, resolve_graphic_params,
+};
+pub use scene::params::{ParamValue, ParamValues};
+pub use stroke::apply_aligned_stroke;
+pub use types::{
+ DEFAULT_GRAPHIC_SOURCE_SIZE, GraphicDefinition, GraphicInstance, GraphicRenderContext,
+ param_number, param_string,
+};
diff --git a/rust/crates/graphics/src/params.rs b/rust/crates/graphics/src/params.rs
new file mode 100644
index 00000000..6c0fedd2
--- /dev/null
+++ b/rust/crates/graphics/src/params.rs
@@ -0,0 +1,72 @@
+use scene::params::{ParamValue, ParamValues};
+
+#[derive(Debug, Clone, Copy, PartialEq, Eq)]
+pub enum ParamGroup {
+ Stroke,
+}
+
+impl ParamGroup {
+ pub fn as_str(&self) -> &'static str {
+ match self {
+ ParamGroup::Stroke => "stroke",
+ }
+ }
+}
+
+#[derive(Debug, Clone, PartialEq)]
+pub struct SelectOption {
+ pub value: &'static str,
+ pub label: &'static str,
+}
+
+#[derive(Debug, Clone, PartialEq)]
+pub enum ParamDefinition {
+ Number {
+ key: &'static str,
+ label: &'static str,
+ group: Option<ParamGroup>,
+ default: f64,
+ min: f64,
+ max: Option<f64>,
+ step: f64,
+ short_label: Option<&'static str>,
+ },
+ Color {
+ key: &'static str,
+ label: &'static str,
+ group: Option<ParamGroup>,
+ default: &'static str,
+ },
+ Select {
+ key: &'static str,
+ label: &'static str,
+ group: Option<ParamGroup>,
+ default: &'static str,
+ options: &'static [SelectOption],
+ },
+}
+
+impl ParamDefinition {
+ pub fn key(&self) -> &'static str {
+ match self {
+ ParamDefinition::Number { key, .. } => key,
+ ParamDefinition::Color { key, .. } => key,
+ ParamDefinition::Select { key, .. } => key,
+ }
+ }
+
+ pub fn default_value(&self) -> ParamValue {
+ match self {
+ ParamDefinition::Number { default, .. } => ParamValue::Number(*default),
+ ParamDefinition::Color { default, .. } => ParamValue::String((*default).to_string()),
+ ParamDefinition::Select { default, .. } => ParamValue::String((*default).to_string()),
+ }
+ }
+}
+
+pub fn build_default_param_values(params: &[ParamDefinition]) -> ParamValues {
+ params
+ .iter()
+ .map(|param| (param.key().to_string(), param.default_value()))
+ .collect()
+}
diff --git a/rust/crates/graphics/src/registry.rs b/rust/crates/graphics/src/registry.rs
new file mode 100644
index 00000000..199d3162
--- /dev/null
+++ b/rust/crates/graphics/src/registry.rs
@@ -0,0 +1,107 @@
+use std::collections::BTreeMap;
+use std::sync::LazyLock;
+
+use scene::params::ParamValues;
+
+use crate::definitions::default_graphic_definitions;
+use crate::error::GraphicsError;
+use crate::params::build_default_param_values;
+use crate::types::{GraphicDefinition, GraphicInstance};
+
+#[derive(Default)]
+pub struct GraphicsRegistry {
+ definitions: BTreeMap<String, &'static GraphicDefinition>,
+}
+
+impl GraphicsRegistry {
+ pub fn new() -> Self {
+ Self::default()
+ }
+
+ pub fn with_defaults() -> Self {
+ let mut registry = Self::new();
+ registry.register_defaults();
+ registry
+ }
+
+ pub fn register_defaults(&mut self) {
+ for definition in default_graphic_definitions() {
+ if self.has(definition.id) {
+ continue;
+ }
+ self.register(definition.id, definition);
+ }
+ }
+
+ pub fn register(&mut self, key: &str, definition: &'static GraphicDefinition) {
+ self.definitions.insert(key.to_string(), definition);
+ }
+
+ pub fn has(&self, key: &str) -> bool {
+ self.definitions.contains_key(key)
+ }
+
+ pub fn get(&self, key: &str) -> Result<&'static GraphicDefinition, GraphicsError> {
+ self.definitions
+ .get(key)
+ .copied()
+ .ok_or_else(|| GraphicsError::UnknownGraphic(key.to_string()))
+ }
+
+ pub fn get_all(&self) -> Vec<&'static GraphicDefinition> {
+ self.definitions.values().copied().collect()
+ }
+}
+
+static REGISTRY: LazyLock<GraphicsRegistry> = LazyLock::new(GraphicsRegistry::with_defaults);
+
+pub fn graphics_registry() -> &'static GraphicsRegistry {
+ &REGISTRY
+}
+
+pub fn get_graphic_definition(
+ definition_id: &str,
+) -> Result<&'static GraphicDefinition, GraphicsError> {
+ graphics_registry().get(definition_id)
+}
+
+pub fn build_default_graphic_instance(
+ definition_id: &str,
+) -> Result<GraphicInstance, GraphicsError> {
+ let definition = get_graphic_definition(definition_id)?;
+ Ok(GraphicInstance {
+ definition_id: definition_id.to_string(),
+ params: build_default_param_values(definition.params),
+ })
+}
+
+pub fn resolve_graphic_params(
+ definition: &GraphicDefinition,
+ params: Option<&ParamValues>,
+) -> ParamValues {
+ let mut resolved = build_default_param_values(definition.params);
+ if let Some(params) = params {
+ resolved.extend(params.clone());
+ }
+ resolved
+}
+
+pub fn render_graphic(
+ definition_id: &str,
+ params: Option<&ParamValues>,
+ width: u32,
+ height: u32,
+) -> Result<tiny_skia::Pixmap, GraphicsError> {
+ let definition = get_graphic_definition(definition_id)?;
+ let resolved = resolve_graphic_params(definition, params);
+ definition.render_to_pixmap(&resolved, width, height)
+}
+
+pub fn render_graphic_rgba8(
+ definition_id: &str,
+ params: Option<&ParamValues>,
+ width: u32,
+ height: u32,
+) -> Result<Vec<u8>, GraphicsError> {
+ Ok(render_graphic(definition_id, params, width, height)?.take())
+}
diff --git a/rust/crates/graphics/src/stroke.rs b/rust/crates/graphics/src/stroke.rs
new file mode 100644
index 00000000..4f2b6f31
--- /dev/null
+++ b/rust/crates/graphics/src/stroke.rs
@@ -0,0 +1,71 @@
+use tiny_skia::{BlendMode, Color, FillRule, Paint, Path, Pixmap, PixmapPaint, Stroke, Transform};
+
+use crate::definitions::StrokeAlign;
+
+pub fn apply_aligned_stroke(
+ pixmap: &mut Pixmap,
+ path: &Path,
+ stroke_width: f64,
+ stroke_align: StrokeAlign,
+ stroke_color: Color,
+) {
+ if stroke_width <= 0.0 {
+ return;
+ }
+
+ match stroke_align {
+ StrokeAlign::Inside => {
+ let Some(mut stroke_pixmap) = Pixmap::new(pixmap.width(), pixmap.height()) else {
+ return;
+ };
+ stroke_path(&mut stroke_pixmap, path, stroke_width * 2.0, stroke_color);
+ erase_outside(&mut stroke_pixmap, path, BlendMode::DestinationIn);
+ composite(pixmap, &stroke_pixmap);
+ }
+ StrokeAlign::Outside => {
+ let Some(mut stroke_pixmap) = Pixmap::new(pixmap.width(), pixmap.height()) else {
+ return;
+ };
+ stroke_path(&mut stroke_pixmap, path, stroke_width * 2.0, stroke_color);
+ erase_outside(&mut stroke_pixmap, path, BlendMode::DestinationOut);
+ composite(pixmap, &stroke_pixmap);
+ }
+ StrokeAlign::Center => {
+ stroke_path(pixmap, path, stroke_width, stroke_color);
+ }
+ }
+}
+
+fn stroke_path(pixmap: &mut Pixmap, path: &Path, width: f64, color: Color) {
+ let paint = Paint {
+ shader: tiny_skia::Shader::SolidColor(color),
+ anti_alias: true,
+ ..Paint::default()
+ };
+ let stroke = Stroke {
+ width: width as f32,
+ ..Stroke::default()
+ };
+ pixmap.stroke_path(path, &paint, &stroke, Transform::default(), None);
+}
+
+fn erase_outside(pixmap: &mut Pixmap, path: &Path, blend_mode: BlendMode) {
+ let paint = Paint {
+ shader: tiny_skia::Shader::SolidColor(Color::BLACK),
+ anti_alias: true,
+ blend_mode,
+ ..Paint::default()
+ };
+ pixmap.fill_path(path, &paint, FillRule::Winding, Transform::default(), None);
+}
+
+fn composite(target: &mut Pixmap, source: &Pixmap) {
+ target.draw_pixmap(
+ 0,
+ 0,
+ source.as_ref(),
+ &PixmapPaint::default(),
+ Transform::default(),
+ None,
+ );
+}
diff --git a/rust/crates/graphics/src/types.rs b/rust/crates/graphics/src/types.rs
new file mode 100644
index 00000000..0a93f53e
--- /dev/null
+++ b/rust/crates/graphics/src/types.rs
@@ -0,0 +1,77 @@
+use scene::params::{ParamValue, ParamValues};
+use tiny_skia::Pixmap;
+
+use crate::error::GraphicsError;
+use crate::params::ParamDefinition;
+
+pub const DEFAULT_GRAPHIC_SOURCE_SIZE: u32 = 512;
+
+pub struct GraphicRenderContext<'a> {
+ pub pixmap: &'a mut Pixmap,
+ pub params: &'a ParamValues,
+ pub width: u32,
+ pub height: u32,
+}
+
+pub struct GraphicDefinition {
+ pub id: &'static str,
+ pub name: &'static str,
+ pub keywords: &'static [&'static str],
+ pub params: &'static [ParamDefinition],
+ pub render: fn(&mut GraphicRenderContext) -> Result<(), GraphicsError>,
+}
+
+impl GraphicDefinition {
+ pub fn render_to_pixmap(
+ &self,
+ params: &ParamValues,
+ width: u32,
+ height: u32,
+ ) -> Result<Pixmap, GraphicsError> {
+ let mut pixmap = Pixmap::new(width, height)
+ .ok_or_else(|| GraphicsError::InvalidColor("invalid size".to_string()))?;
+ let mut context = GraphicRenderContext {
+ pixmap: &mut pixmap,
+ params,
+ width,
+ height,
+ };
+ (self.render)(&mut context)?;
+ Ok(pixmap)
+ }
+
+ pub fn render_rgba8(
+ &self,
+ params: &ParamValues,
+ width: u32,
+ height: u32,
+ ) -> Result<Vec<u8>, GraphicsError> {
+ Ok(self.render_to_pixmap(params, width, height)?.take())
+ }
+}
+
+#[derive(Debug, Clone, PartialEq)]
+pub struct GraphicInstance {
+ pub definition_id: String,
+ pub params: ParamValues,
+}
+
+pub fn param_string(params: &ParamValues, key: &str, default: &str) -> String {
+ match params.get(key) {
+ Some(ParamValue::String(value)) => value.clone(),
+ Some(ParamValue::Number(value)) => value.to_string(),
+ Some(ParamValue::Bool(value)) => value.to_string(),
+ None => default.to_string(),
+ }
+}
+
+pub fn param_number(params: &ParamValues, key: &str, default: f64) -> f64 {
+ match params.get(key) {
+ Some(ParamValue::Number(value)) => *value,
+ Some(ParamValue::String(value)) => value.trim().parse::<f64>().unwrap_or(default),
+ Some(ParamValue::Bool(value)) => {
+ if *value { 1.0 } else { 0.0 }
+ }
+ None => default,
+ }
+}
diff --git a/rust/crates/graphics/tests/gradient_parser.rs b/rust/crates/graphics/tests/gradient_parser.rs
new file mode 100644
index 00000000..4e905ee9
--- /dev/null
+++ b/rust/crates/graphics/tests/gradient_parser.rs
@@ -0,0 +1,375 @@
+use graphics::gradient::ast::*;
+use graphics::gradient::parser::parse_gradient;
+
+fn single(code: &str) -> GradientAst {
+ let ast = parse_gradient(code).unwrap_or_else(|err| panic!("failed to parse {code:?}: {err}"));
+ assert_eq!(ast.len(), 1, "expected one gradient in {code:?}");
+ ast.into_iter().next().unwrap()
+}
+
+#[test]
+fn linear_minimal() {
+ let gradient = single("linear-gradient(red, blue)");
+ assert_eq!(gradient.kind, GradientKind::Linear);
+ assert_eq!(gradient.orientation, None);
+ assert_eq!(
+ gradient.color_stops,
+ vec![
+ ColorStop { color: Color::Literal("red".into()), length: None },
+ ColorStop { color: Color::Literal("blue".into()), length: None },
+ ]
+ );
+}
+
+#[test]
+fn linear_directional() {
+ let gradient = single("linear-gradient(to right, #fff, #000)");
+ assert_eq!(
+ gradient.orientation,
+ Some(GradientOrientation::Linear(LinearOrientation::Directional(
+ "right".into()
+ )))
+ );
+ assert_eq!(gradient.color_stops[0].color, Color::Hex("fff".into()));
+}
+
+#[test]
+fn linear_corner() {
+ let gradient = single("linear-gradient(to left top, red, blue)");
+ assert_eq!(
+ gradient.orientation,
+ Some(GradientOrientation::Linear(LinearOrientation::Directional(
+ "left top".into()
+ )))
+ );
+ let gradient = single("linear-gradient(to bottom right, red, blue)");
+ assert_eq!(
+ gradient.orientation,
+ Some(GradientOrientation::Linear(LinearOrientation::Directional(
+ "bottom right".into()
+ )))
+ );
+}
+
+#[test]
+fn linear_angles() {
+ let gradient = single("linear-gradient(45deg, red, blue)");
+ assert_eq!(
+ gradient.orientation,
+ Some(GradientOrientation::Linear(LinearOrientation::Angular(
+ "45".into()
+ )))
+ );
+ let gradient = single("linear-gradient(-90deg, red, blue)");
+ assert_eq!(
+ gradient.orientation,
+ Some(GradientOrientation::Linear(LinearOrientation::Angular(
+ "-90".into()
+ )))
+ );
+ let gradient = single("linear-gradient(0.5rad, red, blue)");
+ assert_eq!(
+ gradient.orientation,
+ Some(GradientOrientation::Linear(LinearOrientation::Angular(
+ "0.5".into()
+ )))
+ );
+ let gradient = single("linear-gradient(.25deg, red, blue)");
+ assert_eq!(
+ gradient.orientation,
+ Some(GradientOrientation::Linear(LinearOrientation::Angular(
+ ".25".into()
+ )))
+ );
+}
+
+#[test]
+fn linear_legacy_direction() {
+ let gradient = single("linear-gradient(left, red, blue)");
+ assert_eq!(
+ gradient.orientation,
+ Some(GradientOrientation::Linear(LinearOrientation::Directional(
+ "left".into()
+ )))
+ );
+}
+
+#[test]
+fn repeating_and_vendor_prefix() {
+ assert_eq!(
+ single("repeating-linear-gradient(red, blue)").kind,
+ GradientKind::RepeatingLinear
+ );
+ assert_eq!(
+ single("repeating-radial-gradient(red, blue)").kind,
+ GradientKind::RepeatingRadial
+ );
+ assert_eq!(
+ single("-webkit-linear-gradient(red, blue)").kind,
+ GradientKind::Linear
+ );
+ assert_eq!(
+ single("-moz-radial-gradient(red, blue)").kind,
+ GradientKind::Radial
+ );
+ assert_eq!(
+ single("LINEAR-GRADIENT(red, blue)").kind,
+ GradientKind::Linear
+ );
+}
+
+#[test]
+fn radial_shapes() {
+ let gradient = single("radial-gradient(circle, red, blue)");
+ assert_eq!(
+ gradient.orientation,
+ Some(GradientOrientation::Radial(vec![RadialOrientation::Shape(
+ Shape { value: ShapeKind::Circle, style: None, at: None }
+ )]))
+ );
+
+ let gradient = single("radial-gradient(circle 50px, red, blue)");
+ assert_eq!(
+ gradient.orientation,
+ Some(GradientOrientation::Radial(vec![RadialOrientation::Shape(
+ Shape {
+ value: ShapeKind::Circle,
+ style: Some(ShapeStyle::Distance(Distance::Px("50".into()))),
+ at: None,
+ }
+ )]))
+ );
+
+ let gradient = single("radial-gradient(ellipse closest-side, red, blue)");
+ assert_eq!(
+ gradient.orientation,
+ Some(GradientOrientation::Radial(vec![RadialOrientation::Shape(
+ Shape {
+ value: ShapeKind::Ellipse,
+ style: Some(ShapeStyle::ExtentKeyword("closest-side".into())),
+ at: None,
+ }
+ )]))
+ );
+}
+
+#[test]
+fn radial_with_position() {
+ let gradient = single("radial-gradient(circle at 25% 25%, red, blue)");
+ assert_eq!(
+ gradient.orientation,
+ Some(GradientOrientation::Radial(vec![RadialOrientation::Shape(
+ Shape {
+ value: ShapeKind::Circle,
+ style: None,
+ at: Some(Position {
+ x: Some(Distance::Percent("25".into())),
+ y: Some(Distance::Percent("25".into())),
+ }),
+ }
+ )]))
+ );
+
+ let gradient = single("radial-gradient(farthest-corner at 10px 2em, red, blue)");
+ assert_eq!(
+ gradient.orientation,
+ Some(GradientOrientation::Radial(vec![
+ RadialOrientation::ExtentKeyword {
+ value: "farthest-corner".into(),
+ at: Some(Position {
+ x: Some(Distance::Px("10".into())),
+ y: Some(Distance::Em("2".into())),
+ }),
+ }
+ ]))
+ );
+
+ let gradient = single("radial-gradient(at center, red, blue)");
+ assert_eq!(
+ gradient.orientation,
+ Some(GradientOrientation::Radial(vec![
+ RadialOrientation::DefaultRadial {
+ at: Position {
+ x: Some(Distance::PositionKeyword("center".into())),
+ y: None,
+ },
+ }
+ ]))
+ );
+
+ let gradient = single("radial-gradient(100px 50px at 10% 20%, red, blue)");
+ assert_eq!(
+ gradient.orientation,
+ Some(GradientOrientation::Radial(vec![RadialOrientation::Shape(
+ Shape {
+ value: ShapeKind::Ellipse,
+ style: Some(ShapeStyle::Position(Position {
+ x: Some(Distance::Px("100".into())),
+ y: Some(Distance::Px("50".into())),
+ })),
+ at: Some(Position {
+ x: Some(Distance::Percent("10".into())),
+ y: Some(Distance::Percent("20".into())),
+ }),
+ }
+ )]))
+ );
+}
+
+#[test]
+fn radial_multiple_orientations() {
+ let gradient = single("radial-gradient(circle, ellipse, red, blue)");
+ match gradient.orientation {
+ Some(GradientOrientation::Radial(list)) => assert_eq!(list.len(), 2),
+ other => panic!("unexpected orientation: {other:?}"),
+ }
+}
+
+#[test]
+fn color_formats() {
+ let gradient = single("linear-gradient(#abcdef, rgb(1,2,3), rgba(1,2,3,0.5), hsl(120,50%,50%), hsla(120,50%,50%,0.5), var(--brand), rebeccapurple)");
+ assert_eq!(gradient.color_stops[0].color, Color::Hex("abcdef".into()));
+ assert_eq!(
+ gradient.color_stops[1].color,
+ Color::Rgb(vec!["1".into(), "2".into(), "3".into()])
+ );
+ assert_eq!(
+ gradient.color_stops[2].color,
+ Color::Rgba(vec!["1".into(), "2".into(), "3".into(), "0.5".into()])
+ );
+ assert_eq!(
+ gradient.color_stops[3].color,
+ Color::Hsl(["120".into(), "50".into(), "50".into()])
+ );
+ assert_eq!(
+ gradient.color_stops[4].color,
+ Color::Hsla(["120".into(), "50".into(), "50".into(), "0.5".into()])
+ );
+ assert_eq!(gradient.color_stops[5].color, Color::Var("--brand".into()));
+ assert_eq!(
+ gradient.color_stops[6].color,
+ Color::Literal("rebeccapurple".into())
+ );
+}
+
+#[test]
+fn stop_lengths() {
+ let gradient = single("linear-gradient(red 10%, blue 20px, green 3em, white calc(10% + 5px))");
+ assert_eq!(
+ gradient.color_stops[0].length,
+ Some(Distance::Percent("10".into()))
+ );
+ assert_eq!(
+ gradient.color_stops[1].length,
+ Some(Distance::Px("20".into()))
+ );
+ assert_eq!(
+ gradient.color_stops[2].length,
+ Some(Distance::Em("3".into()))
+ );
+ assert_eq!(
+ gradient.color_stops[3].length,
+ Some(Distance::Calc("10% + 5px".into()))
+ );
+}
+
+#[test]
+fn multiple_gradients() {
+ let ast = parse_gradient("linear-gradient(red, blue), radial-gradient(circle, white, black)")
+ .unwrap();
+ assert_eq!(ast.len(), 2);
+ assert_eq!(ast[0].kind, GradientKind::Linear);
+ assert_eq!(ast[1].kind, GradientKind::Radial);
+}
+
+#[test]
+fn trailing_semicolon_and_whitespace() {
+ let ast = parse_gradient(" linear-gradient(red, blue); ").unwrap();
+ assert_eq!(ast.len(), 1);
+}
+
+#[test]
+fn empty_input_is_empty_list() {
+ assert_eq!(parse_gradient("").unwrap(), vec![]);
+}
+
+#[test]
+fn double_space_directional_quirk() {
+ let gradient = single("linear-gradient(to right, red, blue)");
+ assert_eq!(gradient.orientation, None);
+ assert_eq!(gradient.color_stops[0].color, Color::Literal("to".into()));
+ assert_eq!(
+ gradient.color_stops[0].length,
+ Some(Distance::PositionKeyword("right".into()))
+ );
+}
+
+#[test]
+fn error_missing_close_paren() {
+ let err = parse_gradient("linear-gradient(red, blue").unwrap_err();
+ assert_eq!(err.message, "Missing )");
+}
+
+#[test]
+fn error_extra_comma() {
+ let err = parse_gradient("linear-gradient(red,,blue)").unwrap_err();
+ assert_eq!(err.message, "Expected color definition");
+}
+
+#[test]
+fn error_missing_comma_before_color_stops() {
+ let err = parse_gradient("linear-gradient(to left red, blue)").unwrap_err();
+ assert_eq!(err.message, "Missing comma before color stops");
+}
+
+#[test]
+fn error_invalid_input_not_eof() {
+ let err = parse_gradient("foo(red)").unwrap_err();
+ assert_eq!(err.message, "Invalid input not EOF");
+ assert_eq!(err.input, "foo(red)");
+}
+
+#[test]
+fn error_hsl_hue_percentage() {
+ let err = parse_gradient("linear-gradient(hsl(50%, 50%, 50%), red)").unwrap_err();
+ assert!(err.message.starts_with("HSL hue value must be a number"));
+}
+
+#[test]
+fn error_hsl_missing_percentages() {
+ let err = parse_gradient("linear-gradient(hsl(120, 50, 50), red)").unwrap_err();
+ assert_eq!(
+ err.message,
+ "Expected percentage value for saturation and lightness in HSL"
+ );
+}
+
+#[test]
+fn error_rgb_empty() {
+ let err = parse_gradient("linear-gradient(rgb(), red)").unwrap_err();
+ assert_eq!(err.message, "Expected number");
+}
+
+#[test]
+fn error_calc_unbalanced() {
+ let err = parse_gradient("linear-gradient(red calc(10%").unwrap_err();
+ assert_eq!(err.message, "Missing closing parenthesis in calc() expression");
+}
+
+#[test]
+fn error_var_missing_name() {
+ let err = parse_gradient("linear-gradient(var(), red)").unwrap_err();
+ assert_eq!(err.message, "Expected CSS variable name");
+}
+
+#[test]
+fn error_at_missing_position() {
+ let err = parse_gradient("radial-gradient(circle at, red, blue)").unwrap_err();
+ assert_eq!(err.message, "Missing positioning value");
+}
+
+#[test]
+fn error_display_format() {
+ let err = parse_gradient("foo()").unwrap_err();
+ assert_eq!(err.to_string(), "foo(): Invalid input not EOF");
+}
diff --git a/rust/crates/graphics/tests/gradient_stops.rs b/rust/crates/graphics/tests/gradient_stops.rs
new file mode 100644
index 00000000..04adc893
--- /dev/null
+++ b/rust/crates/graphics/tests/gradient_stops.rs
@@ -0,0 +1,176 @@
+use graphics::gradient::ast::{Color, ColorStop, Distance};
+use graphics::gradient::stops::{color_to_string, normalize_color_stops};
+
+fn stop(color: Color, length: Option<Distance>) -> ColorStop {
+ ColorStop { color, length }
+}
+
+fn literal(name: &str) -> Color {
+ Color::Literal(name.to_string())
+}
+
+#[test]
+fn color_string_round_trip() {
+ assert_eq!(color_to_string(&Color::Hex("abc".into())), "#abc");
+ assert_eq!(color_to_string(&literal("red")), "red");
+ assert_eq!(
+ color_to_string(&Color::Rgb(vec!["1".into(), "2".into(), "3".into()])),
+ "rgb(1,2,3)"
+ );
+ assert_eq!(
+ color_to_string(&Color::Rgba(vec!["1".into(), "2".into(), "3".into(), "0.5".into()])),
+ "rgba(1,2,3,0.5)"
+ );
+ assert_eq!(
+ color_to_string(&Color::Hsl(["120".into(), "50".into(), "50".into()])),
+ "hsl(120,50,50)"
+ );
+ assert_eq!(
+ color_to_string(&Color::Hsla(["120".into(), "50".into(), "50".into(), "0.5".into()])),
+ "hsla(120,50,50,0.5)"
+ );
+ assert_eq!(color_to_string(&Color::Var("--x".into())), "var(--x)");
+}
+
+#[test]
+fn evenly_distributed_stops() {
+ let stops = normalize_color_stops(
+ &[stop(literal("red"), None), stop(literal("blue"), None)],
+ 100.0,
+ );
+ assert_eq!(stops[0].offset, 0.0);
+ assert_eq!(stops[1].offset, 1.0);
+
+ let stops = normalize_color_stops(
+ &[
+ stop(literal("a"), None),
+ stop(literal("b"), None),
+ stop(literal("c"), None),
+ ],
+ 100.0,
+ );
+ assert_eq!(stops[1].offset, 0.5);
+}
+
+#[test]
+fn explicit_and_mixed_offsets() {
+ let stops = normalize_color_stops(
+ &[
+ stop(literal("red"), Some(Distance::Percent("20".into()))),
+ stop(literal("green"), None),
+ stop(literal("blue"), Some(Distance::Percent("80".into()))),
+ ],
+ 100.0,
+ );
+ assert_eq!(stops[0].offset, 0.2);
+ assert_eq!(stops[1].offset, 0.5);
+ assert_eq!(stops[2].offset, 0.8);
+}
+
+#[test]
+fn px_and_em_offsets_use_gradient_length() {
+ let stops = normalize_color_stops(
+ &[
+ stop(literal("red"), Some(Distance::Px("50".into()))),
+ stop(literal("blue"), Some(Distance::Em("6.25".into()))),
+ ],
+ 200.0,
+ );
+ assert_eq!(stops[0].offset, 0.25);
+ assert!((stops[1].offset - 0.5).abs() < 1e-9);
+}
+
+#[test]
+fn offsets_clamped() {
+ let stops = normalize_color_stops(
+ &[
+ stop(literal("red"), Some(Distance::Percent("-20".into()))),
+ stop(literal("blue"), Some(Distance::Percent("150".into()))),
+ ],
+ 100.0,
+ );
+ assert_eq!(stops[0].offset, 0.0);
+ assert_eq!(stops[1].offset, 1.0);
+}
+
+#[test]
+fn leading_unpositioned_stops() {
+ let stops = normalize_color_stops(
+ &[
+ stop(literal("a"), None),
+ stop(literal("b"), None),
+ stop(literal("c"), Some(Distance::Percent("90".into()))),
+ ],
+ 100.0,
+ );
+ assert_eq!(stops[0].offset, 0.0);
+ assert_eq!(stops[1].offset, 0.45);
+ assert_eq!(stops[2].offset, 0.9);
+}
+
+#[test]
+fn trailing_unpositioned_stops() {
+ let stops = normalize_color_stops(
+ &[
+ stop(literal("a"), Some(Distance::Percent("10".into()))),
+ stop(literal("b"), None),
+ stop(literal("c"), None),
+ ],
+ 100.0,
+ );
+ assert_eq!(stops[0].offset, 0.1);
+ assert!((stops[1].offset - 0.55).abs() < 1e-9);
+ assert_eq!(stops[2].offset, 1.0);
+}
+
+#[test]
+fn transparent_stop_adopts_previous_color() {
+ let stops = normalize_color_stops(
+ &[
+ stop(Color::Hex("ff0000".into()), None),
+ stop(literal("transparent"), None),
+ stop(Color::Hex("0000ff".into()), None),
+ ],
+ 100.0,
+ );
+ assert_eq!(stops[1].color, "rgba(255,0,0,0)");
+}
+
+#[test]
+fn transparent_stop_with_unparseable_donors_unchanged() {
+ let stops = normalize_color_stops(
+ &[
+ stop(literal("red"), None),
+ stop(literal("transparent"), None),
+ stop(literal("blue"), None),
+ ],
+ 100.0,
+ );
+ assert_eq!(stops[1].color, "transparent");
+}
+
+#[test]
+fn transparent_stop_adopts_next_color_when_first() {
+ let stops = normalize_color_stops(
+ &[
+ stop(literal("transparent"), None),
+ stop(Color::Hex("00ff00".into()), None),
+ ],
+ 100.0,
+ );
+ assert_eq!(stops[1].color, "#00ff00");
+ assert_eq!(stops[0].color, "rgba(0,255,0,0)");
+}
+
+#[test]
+fn all_transparent_stops_unchanged() {
+ let stops = normalize_color_stops(
+ &[
+ stop(literal("transparent"), None),
+ stop(Color::Rgba(vec!["0".into(), "0".into(), "0".into(), "0".into()]), None),
+ ],
+ 100.0,
+ );
+ assert_eq!(stops[0].color, "transparent");
+ assert_eq!(stops[1].color, "rgba(0,0,0,0)");
+}
diff --git a/rust/crates/graphics/tests/graphics_render.rs b/rust/crates/graphics/tests/graphics_render.rs
new file mode 100644
index 00000000..a72f7fc9
--- /dev/null
+++ b/rust/crates/graphics/tests/graphics_render.rs
@@ -0,0 +1,261 @@
+use graphics::color::{hsl_to_rgb, is_transparent, parse_color_to_rgb, parse_css_color};
+use graphics::{
+ GraphicsError, ParamValue, ParamValues, build_default_graphic_instance, draw_css_background,
+ get_graphic_definition, graphics_registry, parse_background_layers, render_graphic,
+ resolve_graphic_params, split_css_layers, BackgroundLayer, GradientKind,
+};
+use tiny_skia::Pixmap;
+
+fn pixel(pixmap: &Pixmap, x: u32, y: u32) -> (u8, u8, u8, u8) {
+ let px = pixmap.pixel(x, y).unwrap();
+ (px.red(), px.green(), px.blue(), px.alpha())
+}
+
+fn params(entries: &[(&str, ParamValue)]) -> ParamValues {
+ entries
+ .iter()
+ .map(|(key, value)| (key.to_string(), value.clone()))
+ .collect()
+}
+
+#[test]
+fn registry_lookup() {
+ let registry = graphics_registry();
+ assert!(registry.has("rectangle"));
+ assert!(registry.has("ellipse"));
+ assert!(registry.has("polygon"));
+ assert!(registry.has("star"));
+ assert_eq!(registry.get_all().len(), 4);
+
+ let rectangle = get_graphic_definition("rectangle").unwrap();
+ assert_eq!(rectangle.name, "Rectangle");
+ assert!(rectangle.keywords.contains(&"box"));
+
+ let err = match get_graphic_definition("nope") {
+ Ok(_) => panic!("expected unknown graphic error"),
+ Err(err) => err,
+ };
+ assert_eq!(err, GraphicsError::UnknownGraphic("nope".to_string()));
+ assert_eq!(err.to_string(), "Unknown graphic: nope");
+}
+
+#[test]
+fn default_instance_and_resolve() {
+ let instance = build_default_graphic_instance("polygon").unwrap();
+ assert_eq!(instance.definition_id, "polygon");
+ assert_eq!(instance.params.get("sides"), Some(&ParamValue::Number(5.0)));
+ assert_eq!(
+ instance.params.get("fill"),
+ Some(&ParamValue::String("#ffffff".to_string()))
+ );
+ assert_eq!(
+ instance.params.get("strokeAlign"),
+ Some(&ParamValue::String("center".to_string()))
+ );
+
+ let definition = get_graphic_definition("star").unwrap();
+ let resolved = resolve_graphic_params(
+ definition,
+ Some(&params(&[("depth", ParamValue::Number(30.0))])),
+ );
+ assert_eq!(resolved.get("depth"), Some(&ParamValue::Number(30.0)));
+ assert_eq!(resolved.get("points"), Some(&ParamValue::Number(5.0)));
+
+ let defaults = resolve_graphic_params(definition, None);
+ assert_eq!(defaults.get("depth"), Some(&ParamValue::Number(45.0)));
+}
+
+#[test]
+fn color_parsing() {
+ let red = parse_css_color("#ff0000").unwrap();
+ assert_eq!((red.r, red.g, red.b, red.a), (1.0, 0.0, 0.0, 1.0));
+ let short = parse_css_color("#f00").unwrap();
+ assert_eq!(short, red);
+ let with_alpha = parse_css_color("#ff000080").unwrap();
+ assert!((with_alpha.a - 128.0 / 255.0).abs() < 1e-9);
+ let named = parse_css_color("rebeccapurple").unwrap();
+ assert!((named.r - 102.0 / 255.0).abs() < 1e-9);
+ assert_eq!(parse_css_color("transparent").unwrap().a, 0.0);
+ let rgb = parse_css_color("rgb(255, 0, 0)").unwrap();
+ assert_eq!(rgb, red);
+ let rgb_percent = parse_css_color("rgb(100%, 0%, 0%)").unwrap();
+ assert!((rgb_percent.r - 1.0).abs() < 1e-9);
+ let rgba = parse_css_color("rgba(0, 255, 0, 0.5)").unwrap();
+ assert_eq!((rgba.g, rgba.a), (1.0, 0.5));
+ let hsl = parse_css_color("hsl(120, 100%, 50%)").unwrap();
+ assert!((hsl.g - 1.0).abs() < 1e-9 && hsl.r.abs() < 1e-9);
+ assert!(parse_css_color("not-a-color").is_none());
+
+ let (r, g, b) = hsl_to_rgb(0.0, 1.0, 0.5);
+ assert_eq!((r, g, b), (1.0, 0.0, 0.0));
+
+ assert!(is_transparent("transparent"));
+ assert!(is_transparent("rgba(1, 2, 3, 0)"));
+ assert!(!is_transparent("rgba(1, 2, 3, 0.5)"));
+ assert!(!is_transparent("#00000000"));
+
+ let parsed = parse_color_to_rgb("#ff0000").unwrap();
+ assert_eq!((parsed.r, parsed.g, parsed.b), (255.0, 0.0, 0.0));
+ assert!(parse_color_to_rgb("#ff00").is_none());
+ assert!(parse_color_to_rgb("hsl(0,100%,50%)").is_none());
+}
+
+#[test]
+fn layer_splitting() {
+ assert_eq!(
+ split_css_layers("#00ff00, linear-gradient(red, blue), rgba(1,2,3,0.5)"),
+ vec!["#00ff00", "linear-gradient(red, blue)", "rgba(1,2,3,0.5)"]
+ );
+
+ let layers = parse_background_layers("linear-gradient(red, blue)");
+ assert!(matches!(
+ &layers[0],
+ BackgroundLayer::Gradient(ast) if ast.kind == GradientKind::Linear
+ ));
+
+ let layers = parse_background_layers("rebeccapurple");
+ assert_eq!(layers, vec![BackgroundLayer::Color("rebeccapurple".to_string())]);
+}
+
+#[test]
+fn rectangle_render_regions() {
+ let pixmap = render_graphic("rectangle", None, 64, 64).unwrap();
+ assert_eq!(pixmap.width(), 64);
+ assert_eq!(pixmap.height(), 64);
+ assert_eq!(pixel(&pixmap, 32, 32), (255, 255, 255, 255));
+ assert_eq!(pixel(&pixmap, 0, 0), (255, 255, 255, 255));
+
+ let rounded = render_graphic(
+ "rectangle",
+ Some(&params(&[("cornerRadius", ParamValue::Number(50.0))])),
+ 64,
+ 64,
+ )
+ .unwrap();
+ assert_eq!(pixel(&rounded, 32, 32), (255, 255, 255, 255));
+ assert_eq!(pixel(&rounded, 0, 0), (0, 0, 0, 0));
+}
+
+#[test]
+fn ellipse_render_regions() {
+ let pixmap = render_graphic("ellipse", None, 64, 64).unwrap();
+ assert_eq!(pixel(&pixmap, 32, 32), (255, 255, 255, 255));
+ assert_eq!(pixel(&pixmap, 0, 0), (0, 0, 0, 0));
+ assert_eq!(pixel(&pixmap, 63, 63), (0, 0, 0, 0));
+}
+
+#[test]
+fn star_render_regions() {
+ let pixmap = render_graphic("star", None, 64, 64).unwrap();
+ assert_eq!(pixel(&pixmap, 32, 32), (255, 255, 255, 255));
+ assert_eq!(pixel(&pixmap, 0, 0), (0, 0, 0, 0));
+}
+
+#[test]
+fn polygon_render_regions() {
+ let pixmap = render_graphic(
+ "polygon",
+ Some(&params(&[("sides", ParamValue::Number(6.0))])),
+ 64,
+ 64,
+ )
+ .unwrap();
+ assert_eq!(pixel(&pixmap, 32, 32), (255, 255, 255, 255));
+ assert_eq!(pixel(&pixmap, 0, 0), (0, 0, 0, 0));
+}
+
+#[test]
+fn center_stroke_regions() {
+ let pixmap = render_graphic(
+ "rectangle",
+ Some(&params(&[
+ ("strokeWidth", ParamValue::Number(10.0)),
+ ("strokeAlign", ParamValue::String("center".to_string())),
+ ])),
+ 64,
+ 64,
+ )
+ .unwrap();
+ assert_eq!(pixel(&pixmap, 2, 32), (0, 0, 0, 255));
+ assert_eq!(pixel(&pixmap, 32, 32), (255, 255, 255, 255));
+}
+
+#[test]
+fn inside_stroke_regions() {
+ let pixmap = render_graphic(
+ "rectangle",
+ Some(&params(&[
+ ("strokeWidth", ParamValue::Number(10.0)),
+ ("strokeAlign", ParamValue::String("inside".to_string())),
+ ])),
+ 64,
+ 64,
+ )
+ .unwrap();
+ assert_eq!(pixel(&pixmap, 4, 32), (0, 0, 0, 255));
+ assert_eq!(pixel(&pixmap, 32, 32), (255, 255, 255, 255));
+}
+
+#[test]
+fn outside_stroke_has_no_inner_ring() {
+ let pixmap = render_graphic(
+ "rectangle",
+ Some(&params(&[
+ ("strokeWidth", ParamValue::Number(10.0)),
+ ("strokeAlign", ParamValue::String("outside".to_string())),
+ ])),
+ 64,
+ 64,
+ )
+ .unwrap();
+ assert_eq!(pixel(&pixmap, 12, 32), (255, 255, 255, 255));
+ assert_eq!(pixel(&pixmap, 32, 32), (255, 255, 255, 255));
+}
+
+#[test]
+fn invalid_fill_color_errors() {
+ let err = render_graphic(
+ "rectangle",
+ Some(&params(&[("fill", ParamValue::String("bogus".to_string()))])),
+ 8,
+ 8,
+ )
+ .unwrap_err();
+ assert!(matches!(err, GraphicsError::InvalidColor(_)));
+}
+
+#[test]
+fn gradient_background_linear() {
+ let mut pixmap = Pixmap::new(64, 64).unwrap();
+ draw_css_background(&mut pixmap, "linear-gradient(to right, #ff0000, #0000ff)").unwrap();
+ let left = pixel(&pixmap, 1, 32);
+ let right = pixel(&pixmap, 62, 32);
+ assert!(left.0 > 200 && left.2 < 60, "left was {left:?}");
+ assert!(right.2 > 200 && right.0 < 60, "right was {right:?}");
+ assert_eq!(left.3, 255);
+ assert_eq!(right.3, 255);
+}
+
+#[test]
+fn gradient_background_radial() {
+ let mut pixmap = Pixmap::new(64, 64).unwrap();
+ draw_css_background(&mut pixmap, "radial-gradient(circle, #ffffff, #000000)").unwrap();
+ let center = pixel(&pixmap, 32, 32);
+ assert!(center.0 > 200, "center was {center:?}");
+ let corner = pixel(&pixmap, 0, 0);
+ assert!(corner.0 < 60, "corner was {corner:?}");
+}
+
+#[test]
+fn background_layer_paint_order() {
+ let mut pixmap = Pixmap::new(16, 16).unwrap();
+ draw_css_background(&mut pixmap, "#00ff00, linear-gradient(#ff0000, #0000ff)").unwrap();
+ assert_eq!(pixel(&pixmap, 8, 8), (0, 255, 0, 255));
+
+ let mut pixmap = Pixmap::new(16, 16).unwrap();
+ draw_css_background(&mut pixmap, "rgba(255,0,0,0.5)").unwrap();
+ let px = pixel(&pixmap, 8, 8);
+ assert!(px.3 > 120 && px.3 < 135, "alpha was {px:?}");
+ assert!(px.0 > 120 && px.0 < 135, "premultiplied red was {px:?}");
+ assert_eq!(px.1, 0);
+}
diff --git a/rust/crates/mask-defs/Cargo.toml b/rust/crates/mask-defs/Cargo.toml
index 7306156d..731e77cc 100644
--- a/rust/crates/mask-defs/Cargo.toml
+++ b/rust/crates/mask-defs/Cargo.toml
@@ -6,3 +6,5 @@ edition = "2024"
[dependencies]
scene = { version = "0.1.0", path = "../scene" }
thiserror = "2"
+tiny-skia = "0.11"
+cosmic-text = "0.15"
diff --git a/rust/crates/mask-defs/src/freeform.rs b/rust/crates/mask-defs/src/freeform.rs
new file mode 100644
index 00000000..6c464977
--- /dev/null
+++ b/rust/crates/mask-defs/src/freeform.rs
@@ -0,0 +1,87 @@
+use scene::FreeformPathPoint;
+use tiny_skia::{Path, PathBuilder};
+
+fn local_to_canvas(
+ x: f64,
+ y: f64,
+ center_x: f64,
+ center_y: f64,
+ rotation: f64,
+ scale: f64,
+ width: f64,
+ height: f64,
+) -> (f64, f64) {
+ let canvas_center_x = width / 2.0 + center_x * width;
+ let canvas_center_y = height / 2.0 + center_y * height;
+ let scaled_x = x * width * scale;
+ let scaled_y = y * height * scale;
+ let angle_rad = rotation.to_radians();
+ let cos = angle_rad.cos();
+ let sin = angle_rad.sin();
+ (
+ canvas_center_x + scaled_x * cos - scaled_y * sin,
+ canvas_center_y + scaled_x * sin + scaled_y * cos,
+ )
+}
+
+#[allow(clippy::too_many_arguments)]
+pub fn build_freeform_path(
+ points: &[FreeformPathPoint],
+ center_x: f64,
+ center_y: f64,
+ rotation: f64,
+ scale: f64,
+ width: f64,
+ height: f64,
+ closed: bool,
+) -> Option<Path> {
+ if points.is_empty() {
+ return None;
+ }
+
+ let to_canvas = |x: f64, y: f64| {
+ local_to_canvas(x, y, center_x, center_y, rotation, scale, width, height)
+ };
+
+ let anchors: Vec<((f64, f64), (f64, f64), (f64, f64))> = points
+ .iter()
+ .map(|point| {
+ (
+ to_canvas(point.x, point.y),
+ to_canvas(point.x + point.in_x, point.y + point.in_y),
+ to_canvas(point.x + point.out_x, point.y + point.out_y),
+ )
+ })
+ .collect();
+
+ let mut builder = PathBuilder::new();
+ builder.move_to(anchors[0].0 .0 as f32, anchors[0].0 .1 as f32);
+ for index in 1..anchors.len() {
+ let previous = anchors[index - 1];
+ let current = anchors[index];
+ builder.cubic_to(
+ previous.2 .0 as f32,
+ previous.2 .1 as f32,
+ current.1 .0 as f32,
+ current.1 .1 as f32,
+ current.0 .0 as f32,
+ current.0 .1 as f32,
+ );
+ }
+
+ if closed && anchors.len() > 1 {
+ let last = anchors[anchors.len() - 1];
+ let first = anchors[0];
+ builder.cubic_to(
+ last.2 .0 as f32,
+ last.2 .1 as f32,
+ first.1 .0 as f32,
+ first.1 .1 as f32,
+ first.0 .0 as f32,
+ first.0 .1 as f32,
+ );
+ builder.close();
+ }
+
+ builder.finish()
+}
diff --git a/rust/crates/mask-defs/src/geometry.rs b/rust/crates/mask-defs/src/geometry.rs
new file mode 100644
index 00000000..c9ae6b66
--- /dev/null
+++ b/rust/crates/mask-defs/src/geometry.rs
@@ -0,0 +1,109 @@
+use scene::{RectangleMaskParams, StrokeAlign};
+
+pub const MIN_MASK_DIMENSION: f64 = 0.01;
+
+pub const NORMAL_SNAP_EPSILON: f64 = 1e-10;
+pub const MIN_POLYGON_AREA_PX: f64 = 0.5;
+pub const INTERSECTION_EPSILON: f64 = 1e-6;
+pub const LINE_PARALLEL_EPSILON: f64 = 1e-10;
+
+#[derive(Clone, Copy, Debug)]
+pub struct BoxGeometry {
+ pub center_x: f64,
+ pub center_y: f64,
+ pub mask_width: f64,
+ pub mask_height: f64,
+ pub rotation_rad: f64,
+}
+
+pub fn box_like_geometry(params: &RectangleMaskParams, width: f64, height: f64) -> BoxGeometry {
+ BoxGeometry {
+ center_x: width / 2.0 + params.center_x * width,
+ center_y: height / 2.0 + params.center_y * height,
+ mask_width: params.width.max(MIN_MASK_DIMENSION) * width,
+ mask_height: params.height.max(MIN_MASK_DIMENSION) * height,
+ rotation_rad: params.rotation.to_radians(),
+ }
+}
+
+pub fn stroke_offset(stroke_align: StrokeAlign, stroke_width: f64) -> f64 {
+ match stroke_align {
+ StrokeAlign::Inside => -(stroke_width / 2.0),
+ StrokeAlign::Outside => stroke_width / 2.0,
+ StrokeAlign::Center => 0.0,
+ }
+}
+
+pub fn rotate_point(
+ x: f64,
+ y: f64,
+ center_x: f64,
+ center_y: f64,
+ rotation_rad: f64,
+) -> (f64, f64) {
+ let dx = x - center_x;
+ let dy = y - center_y;
+ let cos = rotation_rad.cos();
+ let sin = rotation_rad.sin();
+ (center_x + dx * cos - dy * sin, center_y + dx * sin + dy * cos)
+}
+
+#[derive(Clone, Copy, Debug)]
+pub struct SplitLine {
+ pub normal_x: f64,
+ pub normal_y: f64,
+ pub line_x: f64,
+ pub line_y: f64,
+}
+
+pub fn split_line_geometry(
+ center_x: f64,
+ center_y: f64,
+ rotation: f64,
+ width: f64,
+ height: f64,
+) -> SplitLine {
+ let angle_rad = rotation.to_radians();
+ let cos = angle_rad.cos();
+ let sin = angle_rad.sin();
+ SplitLine {
+ normal_x: if cos.abs() < NORMAL_SNAP_EPSILON { 0.0 } else { cos },
+ normal_y: if sin.abs() < NORMAL_SNAP_EPSILON { 0.0 } else { sin },
+ line_x: width / 2.0 + center_x * width,
+ line_y: height / 2.0 + center_y * height,
+ }
+}
+
+pub fn half_plane_sign(line: SplitLine, x: f64, y: f64) -> f64 {
+ (x - line.line_x) * line.normal_x + (y - line.line_y) * line.normal_y
+}
+
+pub fn line_edge_intersection(
+ line: SplitLine,
+ x1: f64,
+ y1: f64,
+ x2: f64,
+ y2: f64,
+) -> Option<(f64, f64)> {
+ let distance1 = half_plane_sign(line, x1, y1);
+ let distance2 = half_plane_sign(line, x2, y2);
+ let denom = distance1 - distance2;
+ if denom.abs() < LINE_PARALLEL_EPSILON {
+ return None;
+ }
+ let t = distance1 / denom;
+ if !(0.0..=1.0).contains(&t) {
+ return None;
+ }
+ Some((x1 + (x2 - x1) * t, y1 + (y2 - y1) * t))
+}
+
+pub fn polygon_area(vertices: &[(f64, f64)]) -> f64 {
+ let mut area = 0.0;
+ for i in 0..vertices.len() {
+ let (x1, y1) = vertices[i];
+ let (x2, y2) = vertices[(i + 1) % vertices.len()];
+ area += x1 * y2 - x2 * y1;
+ }
+ area.abs() * 0.5
+}
diff --git a/rust/crates/mask-defs/src/lib.rs b/rust/crates/mask-defs/src/lib.rs
index 8b137891..c86f5bdf 100644
--- a/rust/crates/mask-defs/src/lib.rs
+++ b/rust/crates/mask-defs/src/lib.rs
@@ -1 +1,192 @@
+mod freeform;
+mod geometry;
+mod paint;
+mod shapes;
+mod split;
+mod text_mask;
+pub use freeform::build_freeform_path;
+
+use scene::Mask;
+use tiny_skia::Pixmap;
+
+#[derive(Debug, thiserror::Error)]
+pub enum MaskRenderError {
+ #[error("invalid pixmap size {width}x{height}")]
+ InvalidSize { width: u32, height: u32 },
+ #[error("invalid stroke color: {0}")]
+ InvalidStrokeColor(String),
+}
+
+fn new_pixmap(width: u32, height: u32) -> Result<Pixmap, MaskRenderError> {
+ Pixmap::new(width, height).ok_or(MaskRenderError::InvalidSize { width, height })
+}
+
+pub fn is_active(mask: &Mask) -> bool {
+ match mask {
+ Mask::Text { params, .. } => !params.content.trim().is_empty(),
+ Mask::Freeform { params, .. } => params.closed,
+ _ => true,
+ }
+}
+
+pub fn feather_for_gpu(mask: &Mask) -> f32 {
+ match mask {
+ Mask::Split { .. } => 0.0,
+ Mask::CinematicBars { params, .. }
+ | Mask::Rectangle { params, .. }
+ | Mask::Ellipse { params, .. }
+ | Mask::Heart { params, .. }
+ | Mask::Diamond { params, .. }
+ | Mask::Star { params, .. } => params.feather as f32,
+ Mask::Text { params, .. } => params.feather as f32,
+ Mask::Freeform { params, .. } => params.feather as f32,
+ }
+}
+
+pub fn render_mask_body(
+ mask: &Mask,
+ width: u32,
+ height: u32,
+) -> Result<Pixmap, MaskRenderError> {
+ let mut pixmap = new_pixmap(width, height)?;
+ let w = width as f64;
+ let h = height as f64;
+ match mask {
+ Mask::Split { params, .. } => {
+ if params.feather == 0.0 {
+ paint::fill_white(&split::split_opaque_body_path(params, w, h), &mut pixmap);
+ } else {
+ split::fill_split_feather_body(params, w, h, &mut pixmap);
+ }
+ }
+ Mask::CinematicBars { params, .. } => {
+ paint::fill_white(&shapes::cinematic_bars_body_path(params, w, h), &mut pixmap);
+ }
+ Mask::Rectangle { params, .. } => {
+ paint::fill_white(&shapes::rectangle_body_path(params, w, h), &mut pixmap);
+ }
+ Mask::Ellipse { params, .. } => {
+ paint::fill_white(&shapes::ellipse_body_path(params, w, h), &mut pixmap);
+ }
+ Mask::Heart { params, .. } => {
+ paint::fill_white(&shapes::heart_body_path(params, w, h), &mut pixmap);
+ }
+ Mask::Diamond { params, .. } => {
+ paint::fill_white(&shapes::diamond_body_path(params, w, h), &mut pixmap);
+ }
+ Mask::Star { params, .. } => {
+ paint::fill_white(&shapes::star_body_path(params, w, h), &mut pixmap);
+ }
+ Mask::Text { params, .. } => {
+ text_mask::render_body(params, w, h, &mut pixmap);
+ }
+ Mask::Freeform { params, .. } => {
+ if params.closed {
+ let path = build_freeform_path(
+ &params.path,
+ params.center_x,
+ params.center_y,
+ params.rotation,
+ params.scale,
+ w,
+ h,
+ true,
+ );
+ paint::fill_white(&path, &mut pixmap);
+ }
+ }
+ }
+ Ok(pixmap)
+}
+
+pub fn render_mask_stroke(
+ mask: &Mask,
+ width: u32,
+ height: u32,
+) -> Result<Option<Pixmap>, MaskRenderError> {
+ let (stroke_width, stroke_color) = match mask {
+ Mask::Split { params, .. } => (params.stroke_width, &params.stroke_color),
+ Mask::CinematicBars { params, .. }
+ | Mask::Rectangle { params, .. }
+ | Mask::Ellipse { params, .. }
+ | Mask::Heart { params, .. }
+ | Mask::Diamond { params, .. }
+ | Mask::Star { params, .. } => (params.stroke_width, &params.stroke_color),
+ Mask::Text { params, .. } => (params.stroke_width, &params.stroke_color),
+ Mask::Freeform { params, .. } => (params.stroke_width, &params.stroke_color),
+ };
+ if stroke_width <= 0.0 {
+ return Ok(None);
+ }
+
+ let mut pixmap = new_pixmap(width, height)?;
+ let w = width as f64;
+ let h = height as f64;
+ match mask {
+ Mask::Split { params, .. } => {
+ let color = paint::parse_stroke_color(stroke_color)?;
+ paint::stroke_path_with(
+ &split::split_stroke_path(params, w, h),
+ &paint::canvas_stroke(stroke_width),
+ color,
+ &mut pixmap,
+ );
+ }
+ Mask::CinematicBars { .. }
+ | Mask::Rectangle { .. }
+ | Mask::Ellipse { .. }
+ | Mask::Heart { .. }
+ | Mask::Diamond { .. }
+ | Mask::Star { .. } => {
+ let path = match mask {
+ Mask::CinematicBars { params, .. } => {
+ shapes::cinematic_bars_stroke_path(params, w, h)
+ }
+ Mask::Rectangle { params, .. } => shapes::rectangle_stroke_path(params, w, h),
+ Mask::Ellipse { params, .. } => shapes::ellipse_stroke_path(params, w, h),
+ Mask::Heart { params, .. } => shapes::heart_stroke_path(params, w, h),
+ Mask::Diamond { params, .. } => shapes::diamond_stroke_path(params, w, h),
+ Mask::Star { params, .. } => shapes::star_stroke_path(params, w, h),
+ _ => unreachable!(),
+ };
+ let color = paint::parse_stroke_color(stroke_color)?;
+ paint::stroke_path_with(
+ &path,
+ &paint::canvas_stroke(stroke_width),
+ color,
+ &mut pixmap,
+ );
+ }
+ Mask::Text { params, .. } => {
+ text_mask::render_stroke(params, w, h, &mut pixmap)?;
+ }
+ Mask::Freeform { params, .. } => {
+ if params.closed {
+ let path = build_freeform_path(
+ &params.path,
+ params.center_x,
+ params.center_y,
+ params.rotation,
+ params.scale,
+ w,
+ h,
+ true,
+ );
+ let color = paint::parse_stroke_color(stroke_color)?;
+ paint::stroke_path_with(
+ &path,
+ &paint::round_stroke(stroke_width),
+ color,
+ &mut pixmap,
+ );
+ if params.stroke_align != scene::StrokeAlign::Center {
+ let mut fill = new_pixmap(width, height)?;
+ paint::fill_white(&path, &mut fill);
+ paint::apply_stroke_align(&mut pixmap, &fill, params.stroke_align);
+ }
+ }
+ }
+ }
+ Ok(Some(pixmap))
+}
diff --git a/rust/crates/mask-defs/src/paint.rs b/rust/crates/mask-defs/src/paint.rs
new file mode 100644
index 00000000..0b122132
--- /dev/null
+++ b/rust/crates/mask-defs/src/paint.rs
@@ -0,0 +1,133 @@
+use scene::StrokeAlign;
+use tiny_skia::{Color, Paint, Path, Pixmap, PremultipliedColorU8, Stroke, Transform};
+
+use crate::MaskRenderError;
+
+pub fn white_paint() -> Paint<'static> {
+ Paint {
+ shader: tiny_skia::Shader::SolidColor(Color::from_rgba8(255, 255, 255, 255)),
+ anti_alias: true,
+ ..Paint::default()
+ }
+}
+
+pub fn fill_white(path: &Option<Path>, pixmap: &mut Pixmap) {
+ if let Some(path) = path {
+ pixmap.fill_path(
+ path,
+ &white_paint(),
+ tiny_skia::FillRule::Winding,
+ Transform::default(),
+ None,
+ );
+ }
+}
+
+pub fn parse_stroke_color(value: &str) -> Result<Color, MaskRenderError> {
+ let hex = value
+ .trim()
+ .strip_prefix('#')
+ .ok_or_else(|| MaskRenderError::InvalidStrokeColor(value.to_string()))?;
+ let expand = |c: u8| (c << 4) | c;
+ let (r, g, b, a) = match hex.len() {
+ 3 => {
+ let v = u32::from_str_radix(hex, 16)
+ .map_err(|_| MaskRenderError::InvalidStrokeColor(value.to_string()))?;
+ (
+ expand(((v >> 8) & 0xF) as u8),
+ expand(((v >> 4) & 0xF) as u8),
+ expand((v & 0xF) as u8),
+ 255,
+ )
+ }
+ 4 => {
+ let v = u32::from_str_radix(hex, 16)
+ .map_err(|_| MaskRenderError::InvalidStrokeColor(value.to_string()))?;
+ (
+ expand(((v >> 12) & 0xF) as u8),
+ expand(((v >> 8) & 0xF) as u8),
+ expand(((v >> 4) & 0xF) as u8),
+ expand((v & 0xF) as u8),
+ )
+ }
+ 6 => {
+ let v = u32::from_str_radix(hex, 16)
+ .map_err(|_| MaskRenderError::InvalidStrokeColor(value.to_string()))?;
+ (((v >> 16) & 0xFF) as u8, ((v >> 8) & 0xFF) as u8, (v & 0xFF) as u8, 255)
+ }
+ 8 => {
+ let v = u32::from_str_radix(hex, 16)
+ .map_err(|_| MaskRenderError::InvalidStrokeColor(value.to_string()))?;
+ (
+ ((v >> 24) & 0xFF) as u8,
+ ((v >> 16) & 0xFF) as u8,
+ ((v >> 8) & 0xFF) as u8,
+ (v & 0xFF) as u8,
+ )
+ }
+ _ => return Err(MaskRenderError::InvalidStrokeColor(value.to_string())),
+ };
+ Ok(Color::from_rgba8(r, g, b, a))
+}
+
+pub fn canvas_stroke(stroke_width: f64) -> Stroke {
+ Stroke {
+ width: stroke_width as f32,
+ miter_limit: 10.0,
+ line_cap: tiny_skia::LineCap::Butt,
+ line_join: tiny_skia::LineJoin::Miter,
+ ..Stroke::default()
+ }
+}
+
+pub fn round_stroke(stroke_width: f64) -> Stroke {
+ Stroke {
+ width: stroke_width as f32,
+ line_cap: tiny_skia::LineCap::Round,
+ line_join: tiny_skia::LineJoin::Round,
+ ..Stroke::default()
+ }
+}
+
+pub fn stroke_path_with(
+ path: &Option<Path>,
+ stroke: &Stroke,
+ color: Color,
+ pixmap: &mut Pixmap,
+) {
+ if let Some(path) = path {
+ let paint = Paint {
+ shader: tiny_skia::Shader::SolidColor(color),
+ anti_alias: true,
+ ..Paint::default()
+ };
+ pixmap.stroke_path(path, &paint, stroke, Transform::default(), None);
+ }
+}
+
+pub fn apply_stroke_align(stroke: &mut Pixmap, fill: &Pixmap, align: StrokeAlign) {
+ let keep_inside = match align {
+ StrokeAlign::Center => return,
+ StrokeAlign::Inside => true,
+ StrokeAlign::Outside => false,
+ };
+ for (dst, src) in stroke.pixels_mut().iter_mut().zip(fill.pixels().iter()) {
+ let coverage = if keep_inside {
+ src.alpha() as u16
+ } else {
+ 255 - src.alpha() as u16
+ };
+ let scale = |channel: u8| ((channel as u16 * coverage + 127) / 255) as u8;
+ let scaled = PremultipliedColorU8::from_rgba(
+ scale(dst.red()),
+ scale(dst.green()),
+ scale(dst.blue()),
+ scale(dst.alpha()),
+ );
+ if let Some(scaled) = scaled {
+ *dst = scaled;
+ } else {
+ *dst = PremultipliedColorU8::from_rgba(0, 0, 0, 0).unwrap();
+ }
+ }
+}
diff --git a/rust/crates/mask-defs/src/shapes.rs b/rust/crates/mask-defs/src/shapes.rs
new file mode 100644
index 00000000..890fc8fe
--- /dev/null
+++ b/rust/crates/mask-defs/src/shapes.rs
@@ -0,0 +1,260 @@
+use scene::RectangleMaskParams;
+use tiny_skia::{Path, PathBuilder, Transform};
+
+use crate::geometry::{box_like_geometry, rotate_point, stroke_offset};
+
+const STAR_INNER_RADIUS_RATIO: f64 = 0.45;
+const STAR_VERTEX_COUNT: usize = 10;
+
+fn polygon_path(points: &[(f64, f64)]) -> Option<Path> {
+ let mut builder = PathBuilder::new();
+ let first = points.first()?;
+ builder.move_to(first.0 as f32, first.1 as f32);
+ for point in &points[1..] {
+ builder.line_to(point.0 as f32, point.1 as f32);
+ }
+ builder.close();
+ builder.finish()
+}
+
+fn rotated_rect_path(
+ center_x: f64,
+ center_y: f64,
+ half_width: f64,
+ half_height: f64,
+ rotation_rad: f64,
+) -> Option<Path> {
+ let corners = [
+ (center_x - half_width, center_y - half_height),
+ (center_x + half_width, center_y - half_height),
+ (center_x + half_width, center_y + half_height),
+ (center_x - half_width, center_y + half_height),
+ ];
+ let rotated: Vec<(f64, f64)> = corners
+ .iter()
+ .map(|&(x, y)| rotate_point(x, y, center_x, center_y, rotation_rad))
+ .collect();
+ polygon_path(&rotated)
+}
+
+fn rotated_ellipse_path(
+ center_x: f64,
+ center_y: f64,
+ radius_x: f64,
+ radius_y: f64,
+ rotation_rad: f64,
+) -> Option<Path> {
+ let rect = tiny_skia::Rect::from_ltrb(
+ -radius_x as f32,
+ -radius_y as f32,
+ radius_x as f32,
+ radius_y as f32,
+ )?;
+ let path = PathBuilder::from_oval(rect)?;
+ let cos = rotation_rad.cos() as f32;
+ let sin = rotation_rad.sin() as f32;
+ path.transform(Transform::from_row(
+ cos,
+ sin,
+ -sin,
+ cos,
+ center_x as f32,
+ center_y as f32,
+ ))
+}
+
+fn heart_path(
+ center_x: f64,
+ center_y: f64,
+ half_width: f64,
+ half_height: f64,
+ rotation_rad: f64,
+) -> Option<Path> {
+ let to_point = |local_x: f64, local_y: f64| {
+ rotate_point(
+ center_x + local_x,
+ center_y + local_y,
+ center_x,
+ center_y,
+ rotation_rad,
+ )
+ };
+
+ let start = to_point(0.0, -half_height * 0.475);
+ let right_control1 = to_point(half_width, -half_height * 1.225);
+ let right_control2 = to_point(half_width, -half_height * 0.125);
+ let bottom = to_point(0.0, half_height * 0.725);
+ let left_control1 = to_point(-half_width, -half_height * 0.125);
+ let left_control2 = to_point(-half_width, -half_height * 1.225);
+
+ let mut builder = PathBuilder::new();
+ builder.move_to(start.0 as f32, start.1 as f32);
+ builder.cubic_to(
+ right_control1.0 as f32,
+ right_control1.1 as f32,
+ right_control2.0 as f32,
+ right_control2.1 as f32,
+ bottom.0 as f32,
+ bottom.1 as f32,
+ );
+ builder.cubic_to(
+ left_control1.0 as f32,
+ left_control1.1 as f32,
+ left_control2.0 as f32,
+ left_control2.1 as f32,
+ start.0 as f32,
+ start.1 as f32,
+ );
+ builder.close();
+ builder.finish()
+}
+
+fn diamond_path(
+ center_x: f64,
+ center_y: f64,
+ half_width: f64,
+ half_height: f64,
+ rotation_rad: f64,
+) -> Option<Path> {
+ let points = [
+ (center_x, center_y - half_height),
+ (center_x + half_width, center_y),
+ (center_x, center_y + half_height),
+ (center_x - half_width, center_y),
+ ];
+ let rotated: Vec<(f64, f64)> = points
+ .iter()
+ .map(|&(x, y)| rotate_point(x, y, center_x, center_y, rotation_rad))
+ .collect();
+ polygon_path(&rotated)
+}
+
+fn star_path(
+ center_x: f64,
+ center_y: f64,
+ half_width: f64,
+ half_height: f64,
+ rotation_rad: f64,
+) -> Option<Path> {
+ let mut points = Vec::with_capacity(STAR_VERTEX_COUNT);
+ for index in 0..STAR_VERTEX_COUNT {
+ let is_outer = index % 2 == 0;
+ let ratio = if is_outer { 1.0 } else { STAR_INNER_RADIUS_RATIO };
+ let angle = (index as f64 * std::f64::consts::PI) / 5.0 - std::f64::consts::FRAC_PI_2;
+ let x = center_x + half_width * ratio * angle.cos();
+ let y = center_y + half_height * ratio * angle.sin();
+ points.push(rotate_point(x, y, center_x, center_y, rotation_rad));
+ }
+ polygon_path(&points)
+}
+
+fn box_like_body_path(
+ params: &RectangleMaskParams,
+ width: f64,
+ height: f64,
+ build: fn(f64, f64, f64, f64, f64) -> Option<Path>,
+) -> Option<Path> {
+ let geometry = box_like_geometry(params, width, height);
+ build(
+ geometry.center_x,
+ geometry.center_y,
+ geometry.mask_width / 2.0,
+ geometry.mask_height / 2.0,
+ geometry.rotation_rad,
+ )
+}
+
+fn box_like_stroke_path(
+ params: &RectangleMaskParams,
+ width: f64,
+ height: f64,
+ build: fn(f64, f64, f64, f64, f64) -> Option<Path>,
+) -> Option<Path> {
+ let geometry = box_like_geometry(params, width, height);
+ let offset = stroke_offset(params.stroke_align, params.stroke_width);
+ build(
+ geometry.center_x,
+ geometry.center_y,
+ (geometry.mask_width / 2.0 + offset).max(1.0),
+ (geometry.mask_height / 2.0 + offset).max(1.0),
+ geometry.rotation_rad,
+ )
+}
+
+pub fn rectangle_body_path(params: &RectangleMaskParams, width: f64, height: f64) -> Option<Path> {
+ box_like_body_path(params, width, height, rotated_rect_path)
+}
+
+pub fn rectangle_stroke_path(params: &RectangleMaskParams, width: f64, height: f64) -> Option<Path> {
+ box_like_stroke_path(params, width, height, rotated_rect_path)
+}
+
+pub fn ellipse_body_path(params: &RectangleMaskParams, width: f64, height: f64) -> Option<Path> {
+ box_like_body_path(params, width, height, rotated_ellipse_path)
+}
+
+pub fn ellipse_stroke_path(params: &RectangleMaskParams, width: f64, height: f64) -> Option<Path> {
+ box_like_stroke_path(params, width, height, rotated_ellipse_path)
+}
+
+pub fn heart_body_path(params: &RectangleMaskParams, width: f64, height: f64) -> Option<Path> {
+ box_like_body_path(params, width, height, heart_path)
+}
+
+pub fn heart_stroke_path(params: &RectangleMaskParams, width: f64, height: f64) -> Option<Path> {
+ box_like_stroke_path(params, width, height, heart_path)
+}
+
+pub fn diamond_body_path(params: &RectangleMaskParams, width: f64, height: f64) -> Option<Path> {
+ box_like_body_path(params, width, height, diamond_path)
+}
+
+pub fn diamond_stroke_path(params: &RectangleMaskParams, width: f64, height: f64) -> Option<Path> {
+ box_like_stroke_path(params, width, height, diamond_path)
+}
+
+pub fn star_body_path(params: &RectangleMaskParams, width: f64, height: f64) -> Option<Path> {
+ box_like_body_path(params, width, height, star_path)
+}
+
+pub fn star_stroke_path(params: &RectangleMaskParams, width: f64, height: f64) -> Option<Path> {
+ box_like_stroke_path(params, width, height, star_path)
+}
+
+fn cinematic_bars_dimensions(params: &RectangleMaskParams, width: f64, height: f64) -> (f64, f64) {
+ (
+ (params.width * width).max(width),
+ params.height.max(0.01) * height,
+ )
+}
+
+pub fn cinematic_bars_body_path(
+ params: &RectangleMaskParams,
+ width: f64,
+ height: f64,
+) -> Option<Path> {
+ let (mask_width, mask_height) = cinematic_bars_dimensions(params, width, height);
+ rotated_rect_path(
+ width / 2.0 + params.center_x * width,
+ height / 2.0 + params.center_y * height,
+ mask_width / 2.0,
+ mask_height / 2.0,
+ params.rotation.to_radians(),
+ )
+}
+
+pub fn cinematic_bars_stroke_path(
+ params: &RectangleMaskParams,
+ width: f64,
+ height: f64,
+) -> Option<Path> {
+ let (mask_width, mask_height) = cinematic_bars_dimensions(params, width, height);
+ let offset = stroke_offset(params.stroke_align, params.stroke_width);
+ rotated_rect_path(
+ width / 2.0 + params.center_x * width,
+ height / 2.0 + params.center_y * height,
+ (mask_width / 2.0 + offset).max(1.0),
+ (mask_height / 2.0 + offset).max(1.0),
+ params.rotation.to_radians(),
+ )
+}
diff --git a/rust/crates/mask-defs/src/split.rs b/rust/crates/mask-defs/src/split.rs
new file mode 100644
index 00000000..d2b67f78
--- /dev/null
+++ b/rust/crates/mask-defs/src/split.rs
@@ -0,0 +1,135 @@
+use scene::SplitMaskParams;
+use tiny_skia::{
+ Color, GradientStop, LinearGradient, Paint, Path, PathBuilder, Pixmap, Point, SpreadMode,
+ Transform,
+};
+
+use crate::geometry::{
+ INTERSECTION_EPSILON, MIN_POLYGON_AREA_PX, SplitLine, half_plane_sign, line_edge_intersection,
+ polygon_area, split_line_geometry,
+};
+
+fn canvas_edges(width: f64, height: f64) -> [(f64, f64, f64, f64); 4] {
+ [
+ (0.0, 0.0, width, 0.0),
+ (width, 0.0, width, height),
+ (width, height, 0.0, height),
+ (0.0, height, 0.0, 0.0),
+ ]
+}
+
+fn points_equal(a: (f64, f64), b: (f64, f64)) -> bool {
+ (a.0 - b.0).abs() <= INTERSECTION_EPSILON && (a.1 - b.1).abs() <= INTERSECTION_EPSILON
+}
+
+fn split_line(params: &SplitMaskParams, width: f64, height: f64) -> SplitLine {
+ split_line_geometry(
+ params.center_x,
+ params.center_y,
+ params.rotation,
+ width,
+ height,
+ )
+}
+
+pub fn split_stroke_segment(
+ params: &SplitMaskParams,
+ width: f64,
+ height: f64,
+) -> Option<[(f64, f64); 2]> {
+ let line = split_line(params, width, height);
+ let mut intersections: Vec<(f64, f64)> = Vec::new();
+ for &(x1, y1, x2, y2) in &canvas_edges(width, height) {
+ let Some(hit) = line_edge_intersection(line, x1, y1, x2, y2) else {
+ continue;
+ };
+ if intersections.iter().any(|&point| points_equal(point, hit)) {
+ continue;
+ }
+ intersections.push(hit);
+ }
+ if intersections.len() != 2 {
+ return None;
+ }
+ Some([intersections[0], intersections[1]])
+}
+
+pub fn split_stroke_path(params: &SplitMaskParams, width: f64, height: f64) -> Option<Path> {
+ let segment = split_stroke_segment(params, width, height)?;
+ let mut builder = PathBuilder::new();
+ builder.move_to(segment[0].0 as f32, segment[0].1 as f32);
+ builder.line_to(segment[1].0 as f32, segment[1].1 as f32);
+ builder.finish()
+}
+
+pub fn split_opaque_body_path(params: &SplitMaskParams, width: f64, height: f64) -> Option<Path> {
+ let line = split_line(params, width, height);
+ let is_inside = |x: f64, y: f64| half_plane_sign(line, x, y) >= 0.0;
+
+ let mut vertices: Vec<(f64, f64)> = Vec::new();
+ for &(x1, y1, x2, y2) in &canvas_edges(width, height) {
+ let vertex1_inside = is_inside(x1, y1);
+ let vertex2_inside = is_inside(x2, y2);
+ if vertex1_inside && vertex2_inside {
+ vertices.push((x2, y2));
+ } else if vertex1_inside {
+ if let Some(hit) = line_edge_intersection(line, x1, y1, x2, y2) {
+ vertices.push(hit);
+ }
+ } else if vertex2_inside {
+ if let Some(hit) = line_edge_intersection(line, x1, y1, x2, y2) {
+ vertices.push(hit);
+ vertices.push((x2, y2));
+ }
+ }
+ }
+
+ if vertices.len() < 3 || polygon_area(&vertices) < MIN_POLYGON_AREA_PX {
+ return None;
+ }
+
+ let mut builder = PathBuilder::new();
+ builder.move_to(vertices[0].0 as f32, vertices[0].1 as f32);
+ for &(x, y) in &vertices[1..] {
+ builder.line_to(x as f32, y as f32);
+ }
+ builder.close();
+ builder.finish()
+}
+
+pub fn fill_split_feather_body(
+ params: &SplitMaskParams,
+ width: f64,
+ height: f64,
+ pixmap: &mut Pixmap,
+) {
+ let line = split_line(params, width, height);
+ let feather_half = params.feather / 2.0;
+ let Some(shader) = LinearGradient::new(
+ Point::from_xy(
+ (line.line_x - line.normal_x * feather_half) as f32,
+ (line.line_y - line.normal_y * feather_half) as f32,
+ ),
+ Point::from_xy(
+ (line.line_x + line.normal_x * feather_half) as f32,
+ (line.line_y + line.normal_y * feather_half) as f32,
+ ),
+ vec![
+ GradientStop::new(0.0, Color::from_rgba8(255, 255, 255, 0)),
+ GradientStop::new(1.0, Color::from_rgba8(255, 255, 255, 255)),
+ ],
+ SpreadMode::Pad,
+ Transform::default(),
+ ) else {
+ return;
+ };
+ let paint = Paint {
+ shader,
+ anti_alias: true,
+ ..Paint::default()
+ };
+ let Some(rect) = tiny_skia::Rect::from_xywh(0.0, 0.0, width as f32, height as f32) else {
+ return;
+ };
+ pixmap.fill_rect(rect, &paint, Transform::default(), None);
+}
diff --git a/rust/crates/mask-defs/src/text_mask.rs b/rust/crates/mask-defs/src/text_mask.rs
new file mode 100644
index 00000000..148df531
--- /dev/null
+++ b/rust/crates/mask-defs/src/text_mask.rs
@@ -0,0 +1,303 @@
+use std::cell::RefCell;
+
+use cosmic_text::{
+ Attrs, Buffer, CacheKey, CacheKeyFlags, Command, Family, FontSystem, Metrics, Shaping,
+ Style, SwashCache, Weight, Wrap, fontdb,
+};
+use scene::{TextDecoration, TextFontStyle, TextFontWeight, TextMaskParams};
+use tiny_skia::{PathBuilder, Pixmap};
+
+use crate::MaskRenderError;
+use crate::paint;
+
+const FONT_SIZE_SCALE_REFERENCE: f64 = 90.0;
+const TEXT_DECORATION_THICKNESS_RATIO: f64 = 0.07;
+const STRIKETHROUGH_VERTICAL_RATIO: f64 = 0.35;
+
+thread_local! {
+ static TEXT_ENGINE: RefCell<(FontSystem, SwashCache)> = RefCell::new({
+ let mut font_system = FontSystem::new();
+ let nixos_fonts = std::path::Path::new("/run/current-system/sw/share/X11/fonts");
+ if nixos_fonts.is_dir() {
+ font_system.db_mut().load_fonts_dir(nixos_fonts);
+ }
+ (font_system, SwashCache::new())
+ });
+}
+
+struct PositionedGlyph {
+ font_id: fontdb::ID,
+ glyph_id: u16,
+ font_size: f32,
+ font_weight: fontdb::Weight,
+ flags: CacheKeyFlags,
+ x: f32,
+ baseline: f32,
+}
+
+struct LineMetrics {
+ y_center: f32,
+ width: f32,
+ ascent: f32,
+ descent: f32,
+}
+
+struct TextLayout {
+ glyphs: Vec<PositionedGlyph>,
+ lines: Vec<LineMetrics>,
+}
+
+#[derive(Clone, Copy)]
+struct TextTransform {
+ cos: f32,
+ sin: f32,
+ scale: f32,
+ translate_x: f32,
+ translate_y: f32,
+}
+
+impl TextTransform {
+ fn new(params: &TextMaskParams, width: f64, height: f64) -> Self {
+ let rotation_rad = params.rotation.to_radians();
+ TextTransform {
+ cos: rotation_rad.cos() as f32,
+ sin: rotation_rad.sin() as f32,
+ scale: params.scale as f32,
+ translate_x: (width / 2.0 + params.center_x * width) as f32,
+ translate_y: (height / 2.0 + params.center_y * height) as f32,
+ }
+ }
+
+ fn map(&self, x: f32, y: f32) -> (f32, f32) {
+ (
+ self.translate_x + self.scale * (x * self.cos - y * self.sin),
+ self.translate_y + self.scale * (x * self.sin + y * self.cos),
+ )
+ }
+}
+
+fn layout_text(
+ font_system: &mut FontSystem,
+ params: &TextMaskParams,
+ canvas_height: f64,
+) -> TextLayout {
+ let empty = TextLayout { glyphs: Vec::new(), lines: Vec::new() };
+ let scaled_font_size = params.font_size * (canvas_height / FONT_SIZE_SCALE_REFERENCE);
+ if !scaled_font_size.is_finite() || scaled_font_size <= 0.0 {
+ return empty;
+ }
+ let mut line_height_px = scaled_font_size * params.line_height;
+ if !line_height_px.is_finite() || line_height_px <= 0.0 {
+ line_height_px = scaled_font_size;
+ }
+
+ let weight = match params.font_weight {
+ TextFontWeight::Normal => Weight::NORMAL,
+ TextFontWeight::Bold => Weight::BOLD,
+ };
+ let style = match params.font_style {
+ TextFontStyle::Normal => Style::Normal,
+ TextFontStyle::Italic => Style::Italic,
+ };
+ let attrs = Attrs::new()
+ .family(Family::Name(&params.font_family))
+ .weight(weight)
+ .style(style)
+ .letter_spacing((params.letter_spacing / scaled_font_size) as f32);
+
+ let content_lines: Vec<&str> = params.content.split('\n').collect();
+ let visual_center_offset =
+ (content_lines.len().saturating_sub(1)) as f64 * line_height_px / 2.0;
+
+ let mut layout = TextLayout { glyphs: Vec::new(), lines: Vec::new() };
+ for (index, line) in content_lines.iter().enumerate() {
+ let y_center = (index as f64 * line_height_px - visual_center_offset) as f32;
+ if line.is_empty() {
+ layout.lines.push(LineMetrics { y_center, width: 0.0, ascent: 0.0, descent: 0.0 });
+ continue;
+ }
+
+ let mut buffer = Buffer::new(
+ font_system,
+ Metrics::new(scaled_font_size as f32, line_height_px as f32),
+ );
+ buffer.set_wrap(font_system, Wrap::None);
+ buffer.set_size(font_system, None, None);
+ buffer.set_text(font_system, line, &attrs, Shaping::Advanced, None);
+ buffer.shape_until_scroll(font_system, false);
+
+ let mut borrowed = buffer.borrow_with(font_system);
+ let Some(layout_lines) = borrowed.line_layout(0) else {
+ continue;
+ };
+ let Some(layout_line) = layout_lines.first() else {
+ continue;
+ };
+
+ let baseline = y_center + (layout_line.max_ascent - layout_line.max_descent) / 2.0;
+ for glyph in &layout_line.glyphs {
+ layout.glyphs.push(PositionedGlyph {
+ font_id: glyph.font_id,
+ glyph_id: glyph.glyph_id,
+ font_size: glyph.font_size,
+ font_weight: glyph.font_weight,
+ flags: glyph.cache_key_flags,
+ x: -layout_line.w / 2.0 + glyph.x + glyph.x_offset * glyph.font_size,
+ baseline: baseline + glyph.y - glyph.y_offset * glyph.font_size,
+ });
+ }
+ layout.lines.push(LineMetrics {
+ y_center,
+ width: layout_line.w,
+ ascent: layout_line.max_ascent,
+ descent: layout_line.max_descent,
+ });
+ }
+ layout
+}
+
+fn push_glyph_outlines(
+ font_system: &mut FontSystem,
+ cache: &mut SwashCache,
+ layout: &TextLayout,
+ transform: &TextTransform,
+ builder: &mut PathBuilder,
+) {
+ for glyph in &layout.glyphs {
+ let (cache_key, _, _) = CacheKey::new(
+ glyph.font_id,
+ glyph.glyph_id,
+ glyph.font_size,
+ (0.0, 0.0),
+ glyph.font_weight,
+ glyph.flags,
+ );
+ let Some(commands) = cache.get_outline_commands(font_system, cache_key) else {
+ continue;
+ };
+ for command in commands {
+ match *command {
+ Command::MoveTo(p) => {
+ let (x, y) = transform.map(glyph.x + p.x, glyph.baseline - p.y);
+ builder.move_to(x, y);
+ }
+ Command::LineTo(p) => {
+ let (x, y) = transform.map(glyph.x + p.x, glyph.baseline - p.y);
+ builder.line_to(x, y);
+ }
+ Command::QuadTo(p1, p2) => {
+ let (x1, y1) = transform.map(glyph.x + p1.x, glyph.baseline - p1.y);
+ let (x2, y2) = transform.map(glyph.x + p2.x, glyph.baseline - p2.y);
+ builder.quad_to(x1, y1, x2, y2);
+ }
+ Command::CurveTo(p1, p2, p3) => {
+ let (x1, y1) = transform.map(glyph.x + p1.x, glyph.baseline - p1.y);
+ let (x2, y2) = transform.map(glyph.x + p2.x, glyph.baseline - p2.y);
+ let (x3, y3) = transform.map(glyph.x + p3.x, glyph.baseline - p3.y);
+ builder.cubic_to(x1, y1, x2, y2, x3, y3);
+ }
+ Command::Close => builder.close(),
+ }
+ }
+ }
+}
+
+fn push_decoration_rect(
+ builder: &mut PathBuilder,
+ transform: &TextTransform,
+ x_start: f32,
+ y_start: f32,
+ width: f32,
+ height: f32,
+) {
+ let corners = [
+ transform.map(x_start, y_start),
+ transform.map(x_start + width, y_start),
+ transform.map(x_start + width, y_start + height),
+ transform.map(x_start, y_start + height),
+ ];
+ builder.move_to(corners[0].0, corners[0].1);
+ for corner in &corners[1..] {
+ builder.line_to(corner.0, corner.1);
+ }
+ builder.close();
+}
+
+fn push_decorations(
+ builder: &mut PathBuilder,
+ params: &TextMaskParams,
+ canvas_height: f64,
+ layout: &TextLayout,
+ transform: &TextTransform,
+) {
+ if params.text_decoration == TextDecoration::None {
+ return;
+ }
+ let scaled_font_size = params.font_size * (canvas_height / FONT_SIZE_SCALE_REFERENCE);
+ let thickness = (scaled_font_size * TEXT_DECORATION_THICKNESS_RATIO).max(1.0) as f32;
+ for line in &layout.lines {
+ if line.width <= 0.0 {
+ continue;
+ }
+ let x_start = -line.width / 2.0;
+ match params.text_decoration {
+ TextDecoration::Underline => {
+ let y = line.y_center + line.descent + thickness;
+ push_decoration_rect(builder, transform, x_start, y, line.width, thickness);
+ }
+ TextDecoration::LineThrough => {
+ let y = line.y_center
+ - (line.ascent - line.descent) * STRIKETHROUGH_VERTICAL_RATIO as f32;
+ push_decoration_rect(builder, transform, x_start, y, line.width, thickness);
+ }
+ TextDecoration::None => {}
+ }
+ }
+}
+
+pub fn render_body(params: &TextMaskParams, width: f64, height: f64, pixmap: &mut Pixmap) {
+ TEXT_ENGINE.with(|engine| {
+ let mut engine = engine.borrow_mut();
+ let (font_system, cache) = &mut *engine;
+ let layout = layout_text(font_system, params, height);
+ let transform = TextTransform::new(params, width, height);
+ let mut builder = PathBuilder::new();
+ push_glyph_outlines(font_system, cache, &layout, &transform, &mut builder);
+ push_decorations(&mut builder, params, height, &layout, &transform);
+ paint::fill_white(&builder.finish(), pixmap);
+ });
+}
+
+pub fn render_stroke(
+ params: &TextMaskParams,
+ width: f64,
+ height: f64,
+ pixmap: &mut Pixmap,
+) -> Result<(), MaskRenderError> {
+ let color = paint::parse_stroke_color(&params.stroke_color)?;
+ TEXT_ENGINE.with(|engine| {
+ let mut engine = engine.borrow_mut();
+ let (font_system, cache) = &mut *engine;
+ let layout = layout_text(font_system, params, height);
+ if layout.glyphs.is_empty() {
+ return;
+ }
+ let transform = TextTransform::new(params, width, height);
+ let mut builder = PathBuilder::new();
+ push_glyph_outlines(font_system, cache, &layout, &transform, &mut builder);
+ paint::stroke_path_with(
+ &builder.finish(),
+ &paint::round_stroke(params.stroke_width * params.scale),
+ color,
+ pixmap,
+ );
+
+ if params.stroke_align != scene::StrokeAlign::Center {
+ if let Some(mut fill) = Pixmap::new(pixmap.width(), pixmap.height()) {
+ render_body(params, width, height, &mut fill);
+ paint::apply_stroke_align(pixmap, &fill, params.stroke_align);
+ }
+ }
+ });
+ Ok(())
+}
diff --git a/rust/crates/mask-defs/tests/render.rs b/rust/crates/mask-defs/tests/render.rs
new file mode 100644
index 00000000..ca8e843a
--- /dev/null
+++ b/rust/crates/mask-defs/tests/render.rs
@@ -0,0 +1,372 @@
+use mask_defs::{feather_for_gpu, is_active, render_mask_body, render_mask_stroke};
+use scene::{
+ FreeformPathMaskParams, FreeformPathPoint, Mask, RectangleMaskParams, SplitMaskParams,
+ StrokeAlign, TextDecoration, TextFontStyle, TextFontWeight, TextMaskParams,
+};
+use tiny_skia::Pixmap;
+
+fn box_params() -> RectangleMaskParams {
+ RectangleMaskParams {
+ feather: 0.0,
+ inverted: false,
+ stroke_color: "#ffffff".to_string(),
+ stroke_width: 0.0,
+ stroke_align: StrokeAlign::Center,
+ center_x: 0.0,
+ center_y: 0.0,
+ width: 0.6,
+ height: 0.6,
+ rotation: 0.0,
+ scale: 1.0,
+ }
+}
+
+fn split_params() -> SplitMaskParams {
+ SplitMaskParams {
+ feather: 0.0,
+ inverted: false,
+ stroke_color: "#ffffff".to_string(),
+ stroke_width: 0.0,
+ stroke_align: StrokeAlign::Center,
+ center_x: 0.0,
+ center_y: 0.0,
+ rotation: 0.0,
+ }
+}
+
+fn text_params(content: &str) -> TextMaskParams {
+ TextMaskParams {
+ feather: 0.0,
+ inverted: false,
+ stroke_color: "#ffffff".to_string(),
+ stroke_width: 0.0,
+ stroke_align: StrokeAlign::Center,
+ content: content.to_string(),
+ font_size: 15.0,
+ font_family: "Arial".to_string(),
+ font_weight: TextFontWeight::Normal,
+ font_style: TextFontStyle::Normal,
+ text_decoration: TextDecoration::None,
+ letter_spacing: 0.0,
+ line_height: 1.2,
+ center_x: 0.0,
+ center_y: 0.0,
+ rotation: 0.0,
+ scale: 1.0,
+ }
+}
+
+fn freeform_params(points: Vec<FreeformPathPoint>, closed: bool) -> FreeformPathMaskParams {
+ FreeformPathMaskParams {
+ feather: 0.0,
+ inverted: false,
+ stroke_color: "#ffffff".to_string(),
+ stroke_width: 0.0,
+ stroke_align: StrokeAlign::Center,
+ path: points,
+ closed,
+ center_x: 0.0,
+ center_y: 0.0,
+ rotation: 0.0,
+ scale: 1.0,
+ }
+}
+
+fn point(id: &str, x: f64, y: f64) -> FreeformPathPoint {
+ FreeformPathPoint {
+ id: id.to_string(),
+ x,
+ y,
+ in_x: 0.0,
+ in_y: 0.0,
+ out_x: 0.0,
+ out_y: 0.0,
+ }
+}
+
+fn alpha_at(pixmap: &Pixmap, x: u32, y: u32) -> u8 {
+ pixmap.pixel(x, y).map(|p| p.alpha()).unwrap_or(0)
+}
+
+fn assert_opaque(pixmap: &Pixmap, x: u32, y: u32) {
+ assert!(
+ alpha_at(pixmap, x, y) > 250,
+ "expected opaque at ({x}, {y}), got {}",
+ alpha_at(pixmap, x, y)
+ );
+}
+
+fn assert_transparent(pixmap: &Pixmap, x: u32, y: u32) {
+ assert!(
+ alpha_at(pixmap, x, y) < 5,
+ "expected transparent at ({x}, {y}), got {}",
+ alpha_at(pixmap, x, y)
+ );
+}
+
+fn box_mask(kind: &str, params: RectangleMaskParams) -> Mask {
+ match kind {
+ "cinematic-bars" => Mask::CinematicBars { id: "m".into(), params },
+ "rectangle" => Mask::Rectangle { id: "m".into(), params },
+ "ellipse" => Mask::Ellipse { id: "m".into(), params },
+ "heart" => Mask::Heart { id: "m".into(), params },
+ "diamond" => Mask::Diamond { id: "m".into(), params },
+ _ => Mask::Star { id: "m".into(), params },
+ }
+}
+
+#[test]
+fn rectangle_body_center_opaque_corners_transparent() {
+ let mask = box_mask("rectangle", box_params());
+ let pixmap = render_mask_body(&mask, 200, 200).unwrap();
+ assert_opaque(&pixmap, 100, 100);
+ assert_opaque(&pixmap, 50, 50);
+ assert_transparent(&pixmap, 20, 20);
+ assert_transparent(&pixmap, 180, 180);
+}
+
+#[test]
+fn rectangle_body_rotated_90_swaps_extents() {
+ let mut params = box_params();
+ params.width = 0.6;
+ params.height = 0.3;
+ params.rotation = 90.0;
+ let mask = box_mask("rectangle", params);
+ let pixmap = render_mask_body(&mask, 200, 100).unwrap();
+ assert_opaque(&pixmap, 100, 50);
+ assert_opaque(&pixmap, 100, 10);
+ assert_transparent(&pixmap, 150, 50);
+}
+
+#[test]
+fn rectangle_stroke_ring_present_when_stroke_width_positive() {
+ let mut params = box_params();
+ params.stroke_width = 10.0;
+ let mask = box_mask("rectangle", params);
+ let stroke = render_mask_stroke(&mask, 200, 200).unwrap().unwrap();
+ assert_opaque(&stroke, 100, 40);
+ assert_transparent(&stroke, 100, 100);
+ assert_transparent(&stroke, 10, 10);
+}
+
+#[test]
+fn stroke_is_none_when_stroke_width_zero() {
+ let mask = box_mask("rectangle", box_params());
+ assert!(render_mask_stroke(&mask, 200, 200).unwrap().is_none());
+}
+
+#[test]
+fn ellipse_body_center_opaque_corners_transparent() {
+ let mask = box_mask("ellipse", box_params());
+ let pixmap = render_mask_body(&mask, 200, 200).unwrap();
+ assert_opaque(&pixmap, 100, 100);
+ assert_opaque(&pixmap, 130, 100);
+ assert_transparent(&pixmap, 45, 45);
+ assert_transparent(&pixmap, 10, 10);
+}
+
+#[test]
+fn heart_body_region_coverage() {
+ let mask = box_mask("heart", box_params());
+ let pixmap = render_mask_body(&mask, 200, 200).unwrap();
+ assert_opaque(&pixmap, 100, 110);
+ assert_opaque(&pixmap, 75, 80);
+ assert_transparent(&pixmap, 100, 60);
+ assert_transparent(&pixmap, 10, 10);
+}
+
+#[test]
+fn diamond_body_region_coverage() {
+ let mask = box_mask("diamond", box_params());
+ let pixmap = render_mask_body(&mask, 200, 200).unwrap();
+ assert_opaque(&pixmap, 100, 100);
+ assert_opaque(&pixmap, 130, 100);
+ assert_transparent(&pixmap, 145, 145);
+ assert_transparent(&pixmap, 10, 10);
+}
+
+#[test]
+fn star_body_region_coverage() {
+ let mask = box_mask("star", box_params());
+ let pixmap = render_mask_body(&mask, 200, 200).unwrap();
+ assert_opaque(&pixmap, 100, 100);
+ assert_opaque(&pixmap, 100, 52);
+ assert_transparent(&pixmap, 121, 71);
+ assert_transparent(&pixmap, 10, 10);
+}
+
+#[test]
+fn cinematic_bars_body_full_width_band() {
+ let mut params = box_params();
+ params.width = 1.5;
+ params.height = 0.6;
+ let mask = box_mask("cinematic-bars", params);
+ let pixmap = render_mask_body(&mask, 200, 100).unwrap();
+ assert_opaque(&pixmap, 100, 50);
+ assert_opaque(&pixmap, 5, 50);
+ assert_transparent(&pixmap, 100, 10);
+ assert_transparent(&pixmap, 100, 95);
+}
+
+#[test]
+fn split_body_zero_feather_hard_edge() {
+ let mask = Mask::Split { id: "m".into(), params: split_params() };
+ let pixmap = render_mask_body(&mask, 200, 100).unwrap();
+ assert_opaque(&pixmap, 150, 50);
+ assert_opaque(&pixmap, 101, 50);
+ assert_transparent(&pixmap, 99, 50);
+ assert_transparent(&pixmap, 50, 50);
+}
+
+#[test]
+fn split_body_feather_gradient_band() {
+ let mut params = split_params();
+ params.feather = 40.0;
+ let mask = Mask::Split { id: "m".into(), params };
+ let pixmap = render_mask_body(&mask, 200, 100).unwrap();
+ assert_opaque(&pixmap, 150, 50);
+ assert_transparent(&pixmap, 50, 50);
+ let mid = alpha_at(&pixmap, 100, 50);
+ assert!(
+ (108..=148).contains(&mid),
+ "expected mid alpha ~128 at split line, got {mid}"
+ );
+ let quarter = alpha_at(&pixmap, 110, 50) as u16;
+ assert!(
+ (170..=215).contains(&quarter),
+ "expected alpha ~191 at feather quarter, got {quarter}"
+ );
+}
+
+#[test]
+fn split_stroke_segment_vertical_and_horizontal() {
+ let mut params = split_params();
+ params.stroke_width = 4.0;
+ let mask = Mask::Split { id: "m".into(), params };
+ let stroke = render_mask_stroke(&mask, 200, 100).unwrap().unwrap();
+ assert_opaque(&stroke, 100, 50);
+ assert_transparent(&stroke, 106, 50);
+
+ let mut rotated = split_params();
+ rotated.rotation = 90.0;
+ rotated.stroke_width = 4.0;
+ let mask = Mask::Split { id: "m".into(), params: rotated };
+ let stroke = render_mask_stroke(&mask, 200, 100).unwrap().unwrap();
+ assert_opaque(&stroke, 50, 50);
+ assert_transparent(&stroke, 50, 56);
+}
+
+#[test]
+fn freeform_closed_triangle_region_coverage() {
+ let points = vec![
+ point("a", -0.4, -0.4),
+ point("b", 0.4, -0.4),
+ point("c", 0.0, 0.4),
+ ];
+ let mask = Mask::Freeform { id: "m".into(), params: freeform_params(points, true) };
+ let pixmap = render_mask_body(&mask, 200, 200).unwrap();
+ assert_opaque(&pixmap, 100, 110);
+ assert_transparent(&pixmap, 10, 10);
+ assert_transparent(&pixmap, 100, 180);
+}
+
+#[test]
+fn freeform_open_path_is_inactive_and_empty() {
+ let points = vec![
+ point("a", -0.4, -0.4),
+ point("b", 0.4, -0.4),
+ point("c", 0.0, 0.4),
+ ];
+ let mask = Mask::Freeform { id: "m".into(), params: freeform_params(points, false) };
+ assert!(!is_active(&mask));
+ let pixmap = render_mask_body(&mask, 200, 200).unwrap();
+ for pixel in pixmap.pixels() {
+ assert_eq!(pixel.alpha(), 0);
+ }
+ let mut stroke_params = freeform_params(
+ vec![point("a", -0.4, -0.4), point("b", 0.4, -0.4), point("c", 0.0, 0.4)],
+ false,
+ );
+ stroke_params.stroke_width = 5.0;
+ let mask = Mask::Freeform { id: "m".into(), params: stroke_params };
+ let stroke = render_mask_stroke(&mask, 200, 200).unwrap().unwrap();
+ for pixel in stroke.pixels() {
+ assert_eq!(pixel.alpha(), 0);
+ }
+}
+
+#[test]
+fn text_body_rasterizes_glyphs() {
+ let mask = Mask::Text { id: "m".into(), params: text_params("Hi") };
+ let pixmap = render_mask_body(&mask, 200, 200).unwrap();
+ let opaque_count = pixmap.pixels().iter().filter(|p| p.alpha() > 200).count();
+ assert!(opaque_count > 20, "expected rasterized glyphs, got {opaque_count} opaque pixels");
+ assert_transparent(&pixmap, 2, 2);
+ assert_transparent(&pixmap, 197, 197);
+}
+
+#[test]
+fn text_stroke_rasterizes_ring() {
+ let mut params = text_params("Hi");
+ params.stroke_width = 3.0;
+ let mask = Mask::Text { id: "m".into(), params };
+ let stroke = render_mask_stroke(&mask, 200, 200).unwrap().unwrap();
+ let opaque_count = stroke.pixels().iter().filter(|p| p.alpha() > 200).count();
+ assert!(opaque_count > 20, "expected stroked glyphs, got {opaque_count} opaque pixels");
+}
+
+#[test]
+fn is_active_rules_match_web() {
+ let text = Mask::Text { id: "m".into(), params: text_params(" ") };
+ assert!(!is_active(&text));
+ let text = Mask::Text { id: "m".into(), params: text_params("Mask") };
+ assert!(is_active(&text));
+ assert!(is_active(&box_mask("rectangle", box_params())));
+ assert!(is_active(&Mask::Split { id: "m".into(), params: split_params() }));
+}
+
+#[test]
+fn feather_for_gpu_zeroes_draw_with_feather_bodies() {
+ let mut split = split_params();
+ split.feather = 40.0;
+ let mask = Mask::Split { id: "m".into(), params: split };
+ assert_eq!(feather_for_gpu(&mask), 0.0);
+
+ let mut rect = box_params();
+ rect.feather = 40.0;
+ let mask = box_mask("rectangle", rect);
+ assert_eq!(feather_for_gpu(&mask), 40.0);
+
+ let mut text = text_params("Hi");
+ text.feather = 25.0;
+ let mask = Mask::Text { id: "m".into(), params: text };
+ assert_eq!(feather_for_gpu(&mask), 25.0);
+}
+
+#[test]
+fn stroke_align_outside_keeps_outer_ring() {
+ let mut params = box_params();
+ params.stroke_width = 10.0;
+ params.stroke_align = StrokeAlign::Outside;
+ let mask = Mask::Freeform {
+ id: "m".into(),
+ params: FreeformPathMaskParams {
+ stroke_width: 10.0,
+ stroke_align: StrokeAlign::Outside,
+ ..freeform_params(
+ vec![
+ point("a", -0.3, -0.3),
+ point("b", 0.3, -0.3),
+ point("c", 0.3, 0.3),
+ point("d", -0.3, 0.3),
+ ],
+ true,
+ )
+ },
+ };
+ let _ = params;
+ let stroke = render_mask_stroke(&mask, 200, 200).unwrap().unwrap();
+ assert_opaque(&stroke, 100, 35);
+ assert_transparent(&stroke, 100, 100);
+ assert_transparent(&stroke, 100, 43);
+}
diff --git a/rust/crates/renderer/Cargo.toml b/rust/crates/renderer/Cargo.toml
new file mode 100644
index 00000000..36d858f2
--- /dev/null
+++ b/rust/crates/renderer/Cargo.toml
@@ -0,0 +1,11 @@
+[package]
+name = "renderer"
+version = "0.1.0"
+edition = "2024"
+
+[dependencies]
+scene = { version = "0.1.0", path = "../scene" }
+compositor = { version = "0.1.0", path = "../compositor" }
+time = { version = "0.1.0", path = "../time" }
+serde = { version = "1.0", features = ["derive"] }
+serde_json = "1.0"
diff --git a/rust/crates/renderer/src/lib.rs b/rust/crates/renderer/src/lib.rs
new file mode 100644
index 00000000..2652bda6
--- /dev/null
+++ b/rust/crates/renderer/src/lib.rs
@@ -0,0 +1,5 @@
+pub mod node;
+pub mod scene_builder;
+
+pub use node::*;
+pub use scene_builder::{BuildSceneParams, build_scene};
diff --git a/rust/crates/renderer/src/node.rs b/rust/crates/renderer/src/node.rs
new file mode 100644
index 00000000..f10c78f9
--- /dev/null
+++ b/rust/crates/renderer/src/node.rs
@@ -0,0 +1,123 @@
+use std::path::PathBuf;
+
+use compositor::BlendMode;
+use scene::{Effect, ElementAnimations, Mask, ParamValues, RetimeConfig, TextElement};
+use time::MediaTime;
+
+#[derive(Clone, Copy, Debug, Default, PartialEq)]
+pub struct Transform {
+ pub scale_x: f64,
+ pub scale_y: f64,
+ pub position_x: f64,
+ pub position_y: f64,
+ pub rotate: f64,
+}
+
+impl Transform {
+ pub fn identity() -> Self {
+ Self {
+ scale_x: 1.0,
+ scale_y: 1.0,
+ ..Default::default()
+ }
+ }
+}
+
+#[derive(Clone, Copy, Debug, Eq, PartialEq)]
+pub enum MediaType {
+ Video,
+ Image,
+ Audio,
+}
+
+#[derive(Clone, Debug)]
+pub struct MediaSource {
+ pub id: String,
+ pub media_type: MediaType,
+ pub path: PathBuf,
+}
+
+#[derive(Clone, Debug)]
+pub struct VisualParams {
+ pub duration: MediaTime,
+ pub time_offset: MediaTime,
+ pub trim_start: MediaTime,
+ pub trim_end: MediaTime,
+ pub retime: Option<RetimeConfig>,
+ pub transform: Transform,
+ pub animations: Option<ElementAnimations>,
+ pub opacity: f64,
+ pub blend_mode: Option<BlendMode>,
+ pub effects: Vec<Effect>,
+ pub masks: Vec<Mask>,
+}
+
+#[derive(Clone, Debug)]
+pub struct TextNodeParams {
+ pub element: TextElement,
+ pub transform: Transform,
+ pub opacity: f64,
+ pub blend_mode: Option<BlendMode>,
+ pub canvas_center_x: f64,
+ pub canvas_center_y: f64,
+ pub canvas_height: f64,
+ pub effects: Vec<Effect>,
+}
+
+#[derive(Clone, Debug)]
+pub struct GraphicNodeParams {
+ pub visual: VisualParams,
+ pub definition_id: String,
+ pub params: ParamValues,
+}
+
+#[derive(Clone, Debug)]
+pub struct BlurBackgroundParams {
+ pub media_id: String,
+ pub path: PathBuf,
+ pub media_type: MediaType,
+ pub duration: MediaTime,
+ pub time_offset: MediaTime,
+ pub trim_start: MediaTime,
+ pub trim_end: MediaTime,
+ pub retime: Option<RetimeConfig>,
+ pub blur_intensity: f64,
+}
+
+#[derive(Clone, Debug)]
+pub struct EffectLayerParams {
+ pub effect_type: String,
+ pub effect_params: ParamValues,
+ pub time_offset: MediaTime,
+ pub duration: MediaTime,
+}
+
+#[derive(Clone, Debug)]
+pub struct StickerNodeParams {
+ pub visual: VisualParams,
+ pub sticker_id: String,
+ pub intrinsic_width: Option<f64>,
+ pub intrinsic_height: Option<f64>,
+}
+
+#[derive(Clone, Debug)]
+pub enum Node {
+ Root { duration: MediaTime, children: Vec<Node> },
+ Color { color: String },
+ BlurBackground(BlurBackgroundParams),
+ EffectLayer(EffectLayerParams),
+ Video { visual: VisualParams, media_id: String, path: PathBuf },
+ Image { visual: VisualParams, path: PathBuf, max_source_size: Option<u32> },
+ Sticker(StickerNodeParams),
+ Graphic(GraphicNodeParams),
+ Text(TextNodeParams),
+}
+
+impl Node {
+ pub fn children(&self) -> &[Node] {
+ match self {
+ Node::Root { children, .. } => children,
+ _ => &[],
+ }
+ }
+}
diff --git a/rust/crates/renderer/src/scene_builder.rs b/rust/crates/renderer/src/scene_builder.rs
new file mode 100644
index 00000000..826eee6f
--- /dev/null
+++ b/rust/crates/renderer/src/scene_builder.rs
@@ -0,0 +1,285 @@
+use std::collections::HashMap;
+
+use compositor::BlendMode;
+use scene::{Background, CanvasSize, Element, ParamValues, SceneTracks, Track};
+use time::MediaTime;
+
+use crate::node::{
+ BlurBackgroundParams, EffectLayerParams, GraphicNodeParams, MediaSource, MediaType, Node,
+ StickerNodeParams, TextNodeParams, Transform, VisualParams,
+};
+
+const PREVIEW_MAX_IMAGE_SIZE: u32 = 2048;
+pub const DEFAULT_BACKGROUND_BLUR_INTENSITY: f64 = 8.0;
+
+pub struct BuildSceneParams<'a> {
+ pub canvas_size: CanvasSize,
+ pub tracks: &'a SceneTracks,
+ pub media: &'a [MediaSource],
+ pub duration: MediaTime,
+ pub background: &'a Background,
+ pub is_preview: bool,
+}
+
+pub fn build_transform_from_params(params: &ParamValues) -> Transform {
+ Transform {
+ scale_x: read_number_param(params, "transform.scaleX", 1.0),
+ scale_y: read_number_param(params, "transform.scaleY", 1.0),
+ position_x: read_number_param(params, "transform.positionX", 0.0),
+ position_y: read_number_param(params, "transform.positionY", 0.0),
+ rotate: read_number_param(params, "transform.rotate", 0.0),
+ }
+}
+
+pub fn read_opacity_from_params(params: &ParamValues) -> f64 {
+ read_number_param(params, "opacity", 1.0)
+}
+
+pub fn read_blend_mode_from_params(params: &ParamValues) -> Option<BlendMode> {
+ let scene::ParamValue::String(value) = params.get("blendMode")? else {
+ return None;
+ };
+ serde_json::from_value(serde_json::Value::String(value.clone())).ok()
+}
+
+fn read_number_param(params: &ParamValues, key: &str, fallback: f64) -> f64 {
+ match params.get(key) {
+ Some(scene::ParamValue::Number(value)) => *value,
+ _ => fallback,
+ }
+}
+
+fn visible_sorted_elements(track: &Track) -> Vec<&Element> {
+ let mut elements: Vec<&Element> = track
+ .elements()
+ .iter()
+ .filter(|element| !element_hidden(element))
+ .collect();
+ elements.sort_by(|a, b| {
+ a.base()
+ .start_time
+ .cmp(&b.base().start_time)
+ .then_with(|| a.base().id.cmp(&b.base().id))
+ });
+ elements
+}
+
+fn element_hidden(element: &Element) -> bool {
+ match element {
+ Element::Video(e) => e.hidden.unwrap_or(false),
+ Element::Image(e) => e.hidden.unwrap_or(false),
+ Element::Text(e) => e.hidden.unwrap_or(false),
+ Element::Sticker(e) => e.hidden.unwrap_or(false),
+ Element::Graphic(e) => e.hidden.unwrap_or(false),
+ _ => false,
+ }
+}
+
+fn visual_params(element: &Element) -> VisualParams {
+ let base = element.base();
+ let (retime, effects, masks) = match element {
+ Element::Video(e) => (
+ e.retime,
+ e.effects.clone().unwrap_or_default(),
+ e.masks.clone().unwrap_or_default(),
+ ),
+ Element::Image(e) => (
+ None,
+ e.effects.clone().unwrap_or_default(),
+ e.masks.clone().unwrap_or_default(),
+ ),
+ Element::Sticker(e) => (None, e.effects.clone().unwrap_or_default(), Vec::new()),
+ Element::Graphic(e) => (
+ None,
+ e.effects.clone().unwrap_or_default(),
+ e.masks.clone().unwrap_or_default(),
+ ),
+ _ => (None, Vec::new(), Vec::new()),
+ };
+ VisualParams {
+ duration: base.duration,
+ time_offset: base.start_time,
+ trim_start: base.trim_start,
+ trim_end: base.trim_end,
+ retime,
+ transform: build_transform_from_params(&base.params),
+ animations: base.animations.clone(),
+ opacity: read_opacity_from_params(&base.params),
+ blend_mode: read_blend_mode_from_params(&base.params),
+ effects,
+ masks,
+ }
+}
+
+fn build_track_nodes(
+ tracks: &[&Track],
+ media_map: &HashMap<&str, &MediaSource>,
+ canvas_size: CanvasSize,
+ is_preview: bool,
+) -> Vec<Node> {
+ let mut nodes = Vec::new();
+
+ for track in tracks {
+ for element in visible_sorted_elements(track) {
+ match element {
+ Element::Effect(effect) => {
+ nodes.push(Node::EffectLayer(EffectLayerParams {
+ effect_type: effect.effect_type.clone(),
+ effect_params: effect.base.params.clone(),
+ time_offset: effect.base.start_time,
+ duration: effect.base.duration,
+ }));
+ }
+ Element::Video(video) => {
+ let Some(asset) = media_map.get(video.media_id.as_str()) else {
+ continue;
+ };
+ if asset.media_type != MediaType::Video {
+ continue;
+ }
+ nodes.push(Node::Video {
+ visual: visual_params(element),
+ media_id: asset.id.clone(),
+ path: asset.path.clone(),
+ });
+ }
+ Element::Image(image) => {
+ let Some(asset) = media_map.get(image.media_id.as_str()) else {
+ continue;
+ };
+ if asset.media_type != MediaType::Image {
+ continue;
+ }
+ nodes.push(Node::Image {
+ visual: visual_params(element),
+ path: asset.path.clone(),
+ max_source_size: is_preview.then_some(PREVIEW_MAX_IMAGE_SIZE),
+ });
+ }
+ Element::Text(text) => {
+ nodes.push(Node::Text(TextNodeParams {
+ element: text.clone(),
+ transform: build_transform_from_params(&text.base.params),
+ opacity: read_opacity_from_params(&text.base.params),
+ blend_mode: read_blend_mode_from_params(&text.base.params),
+ canvas_center_x: canvas_size.width as f64 / 2.0,
+ canvas_center_y: canvas_size.height as f64 / 2.0,
+ canvas_height: canvas_size.height as f64,
+ effects: text.effects.clone().unwrap_or_default(),
+ }));
+ }
+ Element::Sticker(sticker) => {
+ nodes.push(Node::Sticker(StickerNodeParams {
+ visual: visual_params(element),
+ sticker_id: sticker.sticker_id.clone(),
+ intrinsic_width: sticker.intrinsic_width,
+ intrinsic_height: sticker.intrinsic_height,
+ }));
+ }
+ Element::Graphic(graphic) => {
+ nodes.push(Node::Graphic(GraphicNodeParams {
+ visual: visual_params(element),
+ definition_id: graphic.definition_id.clone(),
+ params: graphic.base.params.clone(),
+ }));
+ }
+ Element::Audio(_) => {}
+ }
+ }
+ }
+
+ nodes
+}
+
+fn build_blur_background_nodes(
+ main_track: Option<&Track>,
+ media_map: &HashMap<&str, &MediaSource>,
+ blur_intensity: f64,
+) -> Vec<Node> {
+ let Some(track) = main_track else {
+ return Vec::new();
+ };
+
+ let mut nodes = Vec::new();
+ for element in visible_sorted_elements(track) {
+ let (media_id, retime) = match element {
+ Element::Video(video) => (&video.media_id, video.retime),
+ Element::Image(image) => (&image.media_id, None),
+ _ => continue,
+ };
+ let Some(asset) = media_map.get(media_id.as_str()) else {
+ continue;
+ };
+ if !matches!(asset.media_type, MediaType::Video | MediaType::Image) {
+ continue;
+ }
+ let base = element.base();
+ nodes.push(Node::BlurBackground(BlurBackgroundParams {
+ media_id: asset.id.clone(),
+ path: asset.path.clone(),
+ media_type: asset.media_type,
+ duration: base.duration,
+ time_offset: base.start_time,
+ trim_start: base.trim_start,
+ trim_end: base.trim_end,
+ retime,
+ blur_intensity,
+ }));
+ }
+ nodes
+}
+
+pub fn build_scene(params: &BuildSceneParams) -> Node {
+ let media_map: HashMap<&str, &MediaSource> =
+ params.media.iter().map(|m| (m.id.as_str(), m)).collect();
+
+ let mut visible_tracks: Vec<&Track> = params
+ .tracks
+ .overlay
+ .iter()
+ .filter(|track| !track_hidden(track))
+ .collect();
+ if !track_hidden(&params.tracks.main) {
+ visible_tracks.push(&params.tracks.main);
+ }
+ visible_tracks.reverse();
+
+ let mut children = Vec::new();
+
+ if let Background::Blur { blur_intensity } = params.background {
+ let main_track = (!track_hidden(&params.tracks.main)).then_some(&params.tracks.main);
+ children.extend(build_blur_background_nodes(
+ main_track,
+ &media_map,
+ *blur_intensity,
+ ));
+ } else if let Background::Color { color } = params.background {
+ if color != "transparent" {
+ children.push(Node::Color {
+ color: color.clone(),
+ });
+ }
+ }
+
+ children.extend(build_track_nodes(
+ &visible_tracks,
+ &media_map,
+ params.canvas_size,
+ params.is_preview,
+ ));
+
+ Node::Root {
+ duration: params.duration,
+ children,
+ }
+}
+
+fn track_hidden(track: &Track) -> bool {
+ match track {
+ Track::Video { hidden, .. }
+ | Track::Text { hidden, .. }
+ | Track::Graphic { hidden, .. }
+ | Track::Effect { hidden, .. } => *hidden,
+ Track::Audio { .. } => false,
+ }
+}
diff --git a/rust/crates/renderer/tests/scene_builder.rs b/rust/crates/renderer/tests/scene_builder.rs
new file mode 100644
index 00000000..1d65c092
--- /dev/null
+++ b/rust/crates/renderer/tests/scene_builder.rs
@@ -0,0 +1,228 @@
+use std::path::PathBuf;
+
+use renderer::node::{MediaSource, MediaType, Node};
+use renderer::{BuildSceneParams, build_scene};
+use scene::{
+ Background, BaseTimelineElement, CanvasSize, Element, ImageElement, ParamValues, SceneTracks,
+ TextElement, Track, VideoElement,
+};
+use time::MediaTime;
+
+fn base(id: &str, start: i64) -> BaseTimelineElement {
+ BaseTimelineElement {
+ id: id.to_string(),
+ name: id.to_string(),
+ duration: MediaTime::from_ticks(1000),
+ start_time: MediaTime::from_ticks(start),
+ trim_start: MediaTime::ZERO,
+ trim_end: MediaTime::ZERO,
+ source_duration: None,
+ animations: None,
+ params: ParamValues::new(),
+ }
+}
+
+fn video_element(id: &str, start: i64, media_id: &str) -> Element {
+ Element::Video(VideoElement {
+ base: base(id, start),
+ media_id: media_id.to_string(),
+ is_source_audio_enabled: None,
+ hidden: None,
+ retime: None,
+ effects: None,
+ masks: None,
+ })
+}
+
+fn image_element(id: &str, start: i64, media_id: &str) -> Element {
+ Element::Image(ImageElement {
+ base: base(id, start),
+ media_id: media_id.to_string(),
+ hidden: None,
+ effects: None,
+ masks: None,
+ })
+}
+
+fn text_element(id: &str, start: i64, hidden: Option<bool>) -> Element {
+ Element::Text(TextElement {
+ base: base(id, start),
+ hidden,
+ effects: None,
+ })
+}
+
+fn video_track(id: &str, hidden: bool, elements: Vec<Element>) -> Track {
+ Track::Video {
+ id: id.to_string(),
+ name: id.to_string(),
+ elements,
+ muted: false,
+ hidden,
+ }
+}
+
+fn media(id: &str, media_type: MediaType) -> MediaSource {
+ MediaSource {
+ id: id.to_string(),
+ media_type,
+ path: PathBuf::from(format!("/media/{id}")),
+ }
+}
+
+fn build(tracks: &SceneTracks, media: &[MediaSource], background: &Background) -> Vec<Node> {
+ let params = BuildSceneParams {
+ canvas_size: CanvasSize {
+ width: 1920,
+ height: 1080,
+ },
+ tracks,
+ media,
+ duration: MediaTime::from_ticks(10_000),
+ background,
+ is_preview: false,
+ };
+ match build_scene(&params) {
+ Node::Root { children, .. } => children,
+ _ => panic!("expected root"),
+ }
+}
+
+fn transparent() -> Background {
+ Background::Color {
+ color: "transparent".into(),
+ }
+}
+
+#[test]
+fn skips_elements_without_media_and_mismatched_types() {
+ let tracks = SceneTracks {
+ overlay: vec![],
+ main: video_track(
+ "main",
+ false,
+ vec![
+ video_element("v1", 0, "m1"),
+ video_element("v2", 0, "missing"),
+ image_element("i1", 0, "m1"),
+ ],
+ ),
+ audio: vec![],
+ };
+ let assets = [media("m1", MediaType::Video)];
+ let children = build(&tracks, &assets, &transparent());
+
+ assert_eq!(children.len(), 1);
+ assert!(matches!(&children[0], Node::Video { media_id, .. } if media_id == "m1"));
+}
+
+#[test]
+fn main_track_renders_below_overlay() {
+ let tracks = SceneTracks {
+ overlay: vec![video_track("overlay", false, vec![text_element("t1", 0, None)])],
+ main: video_track("main", false, vec![video_element("v1", 0, "m1")]),
+ audio: vec![],
+ };
+ let assets = [media("m1", MediaType::Video)];
+ let children = build(&tracks, &assets, &transparent());
+
+ assert_eq!(children.len(), 2);
+ assert!(matches!(&children[0], Node::Video { .. }));
+ assert!(matches!(&children[1], Node::Text(_)));
+}
+
+#[test]
+fn hidden_elements_and_tracks_are_skipped() {
+ let tracks = SceneTracks {
+ overlay: vec![video_track("hidden-track", true, vec![text_element("t2", 0, None)])],
+ main: video_track(
+ "main",
+ false,
+ vec![video_element("v1", 0, "m1"), text_element("t1", 0, Some(true))],
+ ),
+ audio: vec![],
+ };
+ let assets = [media("m1", MediaType::Video)];
+ let children = build(&tracks, &assets, &transparent());
+
+ assert_eq!(children.len(), 1);
+ assert!(matches!(&children[0], Node::Video { .. }));
+}
+
+#[test]
+fn elements_sort_by_start_time_then_id() {
+ let tracks = SceneTracks {
+ overlay: vec![],
+ main: video_track(
+ "main",
+ false,
+ vec![
+ video_element("b", 100, "m1"),
+ video_element("a", 100, "m1"),
+ video_element("z", 50, "m1"),
+ ],
+ ),
+ audio: vec![],
+ };
+ let assets = [media("m1", MediaType::Video)];
+ let children = build(&tracks, &assets, &transparent());
+
+ let starts: Vec<i64> = children
+ .iter()
+ .map(|node| match node {
+ Node::Video { visual, .. } => visual.time_offset.as_ticks(),
+ _ => panic!("expected video"),
+ })
+ .collect();
+ assert_eq!(starts, [50, 100, 100]);
+}
+
+#[test]
+fn color_background_comes_first_unless_transparent() {
+ let tracks = SceneTracks {
+ overlay: vec![],
+ main: video_track("main", false, vec![video_element("v1", 0, "m1")]),
+ audio: vec![],
+ };
+ let assets = [media("m1", MediaType::Video)];
+
+ let with_color = build(
+ &tracks,
+ &assets,
+ &Background::Color {
+ color: "#112233".into(),
+ },
+ );
+ assert!(matches!(&with_color[0], Node::Color { color } if color == "#112233"));
+ assert_eq!(with_color.len(), 2);
+
+ let transparent_children = build(&tracks, &assets, &transparent());
+ assert_eq!(transparent_children.len(), 1);
+}
+
+#[test]
+fn blur_background_emits_backdrop_nodes_for_main_media() {
+ let tracks = SceneTracks {
+ overlay: vec![],
+ main: video_track("main", false, vec![video_element("v1", 0, "m1")]),
+ audio: vec![],
+ };
+ let assets = [media("m1", MediaType::Video)];
+ let children = build(
+ &tracks,
+ &assets,
+ &Background::Blur {
+ blur_intensity: 12.0,
+ },
+ );
+
+ assert_eq!(children.len(), 2);
+ match &children[0] {
+ Node::BlurBackground(params) => {
+ assert_eq!(params.blur_intensity, 12.0);
+ assert_eq!(params.media_id, "m1");
+ }
+ _ => panic!("expected blur background first"),
+ }
+ assert!(matches!(&children[1], Node::Video { .. }));
+}
diff --git a/rust/crates/scene/Cargo.toml b/rust/crates/scene/Cargo.toml
index f81e1457..02d80ec3 100644
--- a/rust/crates/scene/Cargo.toml
+++ b/rust/crates/scene/Cargo.toml
@@ -8,3 +8,4 @@ chrono = { version = "0.4", features = ["serde"] }
serde = { version = "1.0", features = ["derive"] }
serde_json = "1.0"
time = { version = "0.1.0", path = "../time" }
+uuid = { version = "1", features = ["v4"] }
diff --git a/rust/crates/scene/src/factory.rs b/rust/crates/scene/src/factory.rs
new file mode 100644
index 00000000..d9a8e8a0
--- /dev/null
+++ b/rust/crates/scene/src/factory.rs
@@ -0,0 +1,113 @@
+use chrono::{DateTime, TimeZone, Utc};
+use time::{FrameRate, MediaTime};
+use uuid::Uuid;
+
+use crate::project::{
+ Background, CanvasSize, CanvasSizeMode, Project, ProjectMetadata, ProjectSettings,
+};
+use crate::scene::Scene;
+use crate::track::{SceneTracks, Track};
+
+pub const MAIN_TRACK_NAME: &str = "Main Track";
+pub const CURRENT_PROJECT_VERSION: u32 = 32;
+pub const DEFAULT_BACKGROUND_COLOR: &str = "#000000";
+pub const DEFAULT_CANVAS_SIZE: CanvasSize = CanvasSize {
+ width: 1920,
+ height: 1080,
+};
+pub const DEFAULT_FPS: FrameRate = FrameRate {
+ numerator: 30,
+ denominator: 1,
+};
+
+pub fn generate_id() -> String {
+ Uuid::new_v4().to_string()
+}
+
+fn now_millis() -> DateTime<Utc> {
+ Utc.timestamp_millis_opt(Utc::now().timestamp_millis()).unwrap()
+}
+
+pub fn build_default_scene(name: impl Into<String>, is_main: bool) -> Scene {
+ let now = now_millis();
+ Scene {
+ id: generate_id(),
+ name: name.into(),
+ is_main,
+ tracks: SceneTracks {
+ overlay: Vec::new(),
+ main: Track::Video {
+ id: generate_id(),
+ name: MAIN_TRACK_NAME.to_string(),
+ elements: Vec::new(),
+ muted: false,
+ hidden: false,
+ },
+ audio: Vec::new(),
+ },
+ bookmarks: Vec::new(),
+ created_at: now,
+ updated_at: now,
+ }
+}
+
+pub fn new_project(name: impl Into<String>) -> Project {
+ let main_scene = build_default_scene("Main scene", true);
+ let now = now_millis();
+ Project {
+ metadata: ProjectMetadata {
+ id: generate_id(),
+ name: name.into(),
+ thumbnail: None,
+ duration: MediaTime::ZERO,
+ created_at: now,
+ updated_at: now,
+ },
+ current_scene_id: main_scene.id.clone(),
+ scenes: vec![main_scene],
+ settings: ProjectSettings {
+ fps: DEFAULT_FPS,
+ canvas_size: DEFAULT_CANVAS_SIZE,
+ canvas_size_mode: Some(CanvasSizeMode::Preset),
+ last_custom_canvas_size: None,
+ original_canvas_size: None,
+ background: Background::Color {
+ color: DEFAULT_BACKGROUND_COLOR.to_string(),
+ },
+ },
+ version: CURRENT_PROJECT_VERSION,
+ timeline_view_state: None,
+ }
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+
+ #[test]
+ fn new_project_matches_web_defaults() {
+ let project = new_project("Untitled Project");
+ assert_eq!(project.version, 32);
+ assert_eq!(project.scenes.len(), 1);
+ assert!(project.scenes[0].is_main);
+ assert_eq!(project.current_scene_id, project.scenes[0].id);
+ assert_eq!(project.settings.canvas_size.width, 1920);
+ assert_eq!(project.settings.fps.numerator, 30);
+ match &project.scenes[0].tracks.main {
+ Track::Video { name, .. } => assert_eq!(name, "Main Track"),
+ _ => panic!("main track must be video"),
+ }
+ match &project.settings.background {
+ Background::Color { color } => assert_eq!(color, "#000000"),
+ _ => panic!("default background must be color"),
+ }
+ }
+
+ #[test]
+ fn new_project_round_trips_through_json() {
+ let project = new_project("Test");
+ let json = serde_json::to_value(&project).unwrap();
+ let parsed: Project = serde_json::from_value(json).unwrap();
+ assert_eq!(parsed, project);
+ }
+}
diff --git a/rust/crates/scene/src/lib.rs b/rust/crates/scene/src/lib.rs
index d18925ff..efc778d7 100644
--- a/rust/crates/scene/src/lib.rs
+++ b/rust/crates/scene/src/lib.rs
@@ -1,6 +1,7 @@
pub mod animation;
pub mod effect;
pub mod element;
+pub mod factory;
pub mod iso_date;
pub mod mask;
pub mod params;
@@ -11,6 +12,7 @@ pub mod track;
pub use animation::*;
pub use effect::*;
pub use element::*;
+pub use factory::*;
pub use mask::*;
pub use params::*;
pub use project::*;
diff --git a/rust/crates/text/src/color.rs b/rust/crates/text/src/color.rs
new file mode 100644
index 00000000..fa6bd3e9
--- /dev/null
+++ b/rust/crates/text/src/color.rs
@@ -0,0 +1,74 @@
+#[derive(Clone, Copy, Debug, PartialEq, Eq)]
+pub struct Color {
+ pub r: u8,
+ pub g: u8,
+ pub b: u8,
+ pub a: u8,
+}
+
+impl Color {
+ pub const TRANSPARENT: Self = Self {
+ r: 0,
+ g: 0,
+ b: 0,
+ a: 0,
+ };
+
+ pub const fn rgba(r: u8, g: u8, b: u8, a: u8) -> Self {
+ Self { r, g, b, a }
+ }
+
+ pub const fn rgb(r: u8, g: u8, b: u8) -> Self {
+ Self { r, g, b, a: 0xFF }
+ }
+
+ pub fn parse(value: &str) -> Option<Self> {
+ if value == "transparent" {
+ return Some(Self::TRANSPARENT);
+ }
+ let hex = value.strip_prefix('#')?;
+ let channel = |pair: &str| u8::from_str_radix(pair, 16).ok();
+ match hex.len() {
+ 3 => {
+ let r = channel(&hex[0..1].repeat(2))?;
+ let g = channel(&hex[1..2].repeat(2))?;
+ let b = channel(&hex[2..3].repeat(2))?;
+ Some(Self::rgb(r, g, b))
+ }
+ 4 => {
+ let r = channel(&hex[0..1].repeat(2))?;
+ let g = channel(&hex[1..2].repeat(2))?;
+ let b = channel(&hex[2..3].repeat(2))?;
+ let a = channel(&hex[3..4].repeat(2))?;
+ Some(Self::rgba(r, g, b, a))
+ }
+ 6 => {
+ let r = channel(&hex[0..2])?;
+ let g = channel(&hex[2..4])?;
+ let b = channel(&hex[4..6])?;
+ Some(Self::rgb(r, g, b))
+ }
+ 8 => {
+ let r = channel(&hex[0..2])?;
+ let g = channel(&hex[2..4])?;
+ let b = channel(&hex[4..6])?;
+ let a = channel(&hex[6..8])?;
+ Some(Self::rgba(r, g, b, a))
+ }
+ _ => None,
+ }
+ }
+
+ pub(crate) fn to_cosmic(self) -> cosmic_text::Color {
+ cosmic_text::Color::rgba(self.r, self.g, self.b, self.a)
+ }
+
+ pub(crate) fn from_cosmic(color: cosmic_text::Color) -> Self {
+ Self::rgba(color.r(), color.g(), color.b(), color.a())
+ }
+
+ pub(crate) fn scaled_alpha(self, coverage: f64) -> Self {
+ let alpha = (f64::from(self.a) * coverage).round() as u8;
+ Self::rgba(self.r, self.g, self.b, alpha)
+ }
+}
diff --git a/rust/crates/text/src/engine.rs b/rust/crates/text/src/engine.rs
new file mode 100644
index 00000000..9d0289fd
--- /dev/null
+++ b/rust/crates/text/src/engine.rs
@@ -0,0 +1,194 @@
+use cosmic_text::fontdb::Source;
+use cosmic_text::{Attrs, Buffer, Family, FontSystem, Metrics, Shaping, Style, SwashCache, Weight, Wrap};
+use std::sync::Arc;
+
+use crate::layout::{
+ LineMetrics, ResolvedTextLayout, TextBlockMeasurement, measure_text_block, resolve_text_layout,
+};
+use crate::params::{FontStyle, FontWeight, TextLayoutParams};
+
+#[derive(Debug, thiserror::Error)]
+pub enum TextError {
+ #[error("failed to load font: {0}")]
+ FontLoad(String),
+}
+
+pub struct TextEngine {
+ pub(crate) font_system: FontSystem,
+ pub(crate) swash_cache: SwashCache,
+}
+
+pub struct MeasuredTextLayout {
+ pub resolved: ResolvedTextLayout,
+ pub lines: Vec<String>,
+ pub line_metrics: Vec<LineMetrics>,
+ pub block: TextBlockMeasurement,
+ pub(crate) buffer: Buffer,
+}
+
+impl TextEngine {
+ pub fn new() -> Self {
+ Self::from_font_system(FontSystem::new())
+ }
+
+ pub fn with_font_bytes(fonts: &[&[u8]]) -> Self {
+ let sources = fonts
+ .iter()
+ .map(|bytes| Source::Binary(Arc::new(bytes.to_vec())));
+ Self::from_font_system(FontSystem::new_with_fonts(sources))
+ }
+
+ fn from_font_system(font_system: FontSystem) -> Self {
+ Self {
+ font_system,
+ swash_cache: SwashCache::new(),
+ }
+ }
+
+ pub fn load_font_bytes(&mut self, bytes: &[u8]) -> Result<(), TextError> {
+ let loaded = self
+ .font_system
+ .db_mut()
+ .load_font_source(Source::Binary(Arc::new(bytes.to_vec())));
+ if loaded.is_empty() {
+ return Err(TextError::FontLoad(
+ "no faces found in font data".to_string(),
+ ));
+ }
+ Ok(())
+ }
+
+ pub fn measure(&mut self, params: &TextLayoutParams, canvas_height: f64) -> MeasuredTextLayout {
+ self.measure_with_wrap(params, canvas_height, None)
+ }
+
+ pub fn measure_with_wrap(
+ &mut self,
+ params: &TextLayoutParams,
+ canvas_height: f64,
+ wrap_width: Option<f64>,
+ ) -> MeasuredTextLayout {
+ let resolved = resolve_text_layout(params, canvas_height);
+ let mut buffer = self.build_buffer(params, &resolved, wrap_width);
+ buffer.shape_until_scroll(&mut self.font_system, false);
+
+ let mut lines = Vec::new();
+ let mut line_metrics = Vec::new();
+ for line_i in 0..buffer.lines.len() {
+ let line_text = buffer.lines[line_i].text().to_string();
+ let layouts = buffer
+ .line_layout(&mut self.font_system, line_i)
+ .unwrap_or(&[]);
+ let mut laid_out = false;
+ for layout_line in layouts {
+ laid_out = true;
+ let (text, metrics) = visual_line_metrics(&line_text, layout_line, &resolved);
+ lines.push(text);
+ line_metrics.push(metrics);
+ }
+ if !laid_out {
+ lines.push(line_text);
+ line_metrics.push(empty_line_metrics(&resolved));
+ }
+ }
+ if line_metrics.is_empty() {
+ lines.push(String::new());
+ line_metrics.push(empty_line_metrics(&resolved));
+ }
+
+ let block = measure_text_block(&line_metrics, resolved.line_height_px);
+ MeasuredTextLayout {
+ resolved,
+ lines,
+ line_metrics,
+ block,
+ buffer,
+ }
+ }
+
+ fn build_buffer(
+ &mut self,
+ params: &TextLayoutParams,
+ resolved: &ResolvedTextLayout,
+ wrap_width: Option<f64>,
+ ) -> Buffer {
+ let metrics = Metrics::new(
+ resolved.scaled_font_size as f32,
+ resolved.line_height_px as f32,
+ );
+ let mut buffer = Buffer::new(&mut self.font_system, metrics);
+ let (wrap, width) = match wrap_width {
+ Some(width) => (Wrap::WordOrGlyph, Some(width as f32)),
+ None => (Wrap::None, None),
+ };
+ buffer.set_wrap(&mut self.font_system, wrap);
+ buffer.set_size(&mut self.font_system, width, None);
+ let attrs = Attrs::new()
+ .family(Family::Name(&params.font_family))
+ .weight(match params.font_weight {
+ FontWeight::Normal => Weight::NORMAL,
+ FontWeight::Bold => Weight::BOLD,
+ })
+ .style(match params.font_style {
+ FontStyle::Normal => Style::Normal,
+ FontStyle::Italic => Style::Italic,
+ })
+ .letter_spacing((resolved.letter_spacing / resolved.scaled_font_size.max(f64::EPSILON)) as f32);
+ buffer.set_text(
+ &mut self.font_system,
+ &params.content,
+ &attrs,
+ Shaping::Advanced,
+ None,
+ );
+ buffer
+ }
+}
+
+impl Default for TextEngine {
+ fn default() -> Self {
+ Self::new()
+ }
+}
+
+fn visual_line_metrics(
+ line_text: &str,
+ layout_line: &cosmic_text::LayoutLine,
+ resolved: &ResolvedTextLayout,
+) -> (String, LineMetrics) {
+ if layout_line.glyphs.is_empty() {
+ return (line_text.to_string(), empty_line_metrics(resolved));
+ }
+ let raw_width = layout_line
+ .glyphs
+ .iter()
+ .fold(0.0_f32, |max, glyph| max.max(glyph.x + glyph.w));
+ let width = (f64::from(raw_width) - resolved.letter_spacing).max(0.0);
+ let start = layout_line
+ .glyphs
+ .iter()
+ .map(|glyph| glyph.start)
+ .min()
+ .unwrap_or(0);
+ let end = layout_line
+ .glyphs
+ .iter()
+ .map(|glyph| glyph.end)
+ .max()
+ .unwrap_or(line_text.len());
+ let text = line_text.get(start..end).unwrap_or(line_text).to_string();
+ let metrics = LineMetrics {
+ width,
+ ascent: f64::from(layout_line.max_ascent),
+ descent: f64::from(layout_line.max_descent),
+ };
+ (text, metrics)
+}
+
+fn empty_line_metrics(resolved: &ResolvedTextLayout) -> LineMetrics {
+ LineMetrics {
+ width: 0.0,
+ ascent: resolved.scaled_font_size * 0.8,
+ descent: resolved.scaled_font_size * 0.2,
+ }
+}
diff --git a/rust/crates/text/src/layout.rs b/rust/crates/text/src/layout.rs
new file mode 100644
index 00000000..c0e3a198
--- /dev/null
+++ b/rust/crates/text/src/layout.rs
@@ -0,0 +1,163 @@
+use crate::params::{
+ FONT_SIZE_SCALE_REFERENCE, TextAlign, TextBackground, TextDecoration, TextLayoutParams,
+};
+
+pub const TEXT_DECORATION_THICKNESS_RATIO: f64 = 0.07;
+pub const STRIKETHROUGH_VERTICAL_RATIO: f64 = 0.35;
+
+#[derive(Clone, Copy, Debug, PartialEq)]
+pub struct ResolvedTextLayout {
+ pub scaled_font_size: f64,
+ pub letter_spacing: f64,
+ pub line_height_px: f64,
+ pub font_size_ratio: f64,
+ pub text_align: TextAlign,
+ pub text_decoration: TextDecoration,
+}
+
+pub fn resolve_text_layout(text: &TextLayoutParams, canvas_height: f64) -> ResolvedTextLayout {
+ let scaled_font_size = text.font_size * (canvas_height / FONT_SIZE_SCALE_REFERENCE);
+ ResolvedTextLayout {
+ scaled_font_size,
+ letter_spacing: text.letter_spacing,
+ line_height_px: scaled_font_size * text.line_height,
+ font_size_ratio: text.font_size / 15.0,
+ text_align: text.text_align,
+ text_decoration: text.text_decoration,
+ }
+}
+
+#[derive(Clone, Copy, Debug, Default, PartialEq)]
+pub struct LineMetrics {
+ pub width: f64,
+ pub ascent: f64,
+ pub descent: f64,
+}
+
+#[derive(Clone, Copy, Debug, Default, PartialEq)]
+pub struct TextBlockMeasurement {
+ pub visual_center_offset: f64,
+ pub height: f64,
+ pub max_width: f64,
+}
+
+pub fn measure_text_block(
+ line_metrics: &[LineMetrics],
+ line_height_px: f64,
+) -> TextBlockMeasurement {
+ let max_width = line_metrics
+ .iter()
+ .fold(0.0_f64, |max, metrics| max.max(metrics.width));
+ let line_count = line_metrics.len() as f64;
+ TextBlockMeasurement {
+ visual_center_offset: ((line_count - 1.0) * line_height_px) / 2.0,
+ height: line_count * line_height_px,
+ max_width,
+ }
+}
+
+#[derive(Clone, Copy, Debug, Default, PartialEq)]
+pub struct Rect {
+ pub left: f64,
+ pub top: f64,
+ pub width: f64,
+ pub height: f64,
+}
+
+impl Rect {
+ pub fn right(self) -> f64 {
+ self.left + self.width
+ }
+
+ pub fn bottom(self) -> f64 {
+ self.top + self.height
+ }
+}
+
+pub fn align_offset(text_align: TextAlign, line_width: f64) -> f64 {
+ match text_align {
+ TextAlign::Left => 0.0,
+ TextAlign::Center => -line_width / 2.0,
+ TextAlign::Right => -line_width,
+ }
+}
+
+pub fn text_rect(text_align: TextAlign, block: &TextBlockMeasurement) -> Rect {
+ Rect {
+ left: align_offset(text_align, block.max_width),
+ top: -block.height / 2.0,
+ width: block.max_width,
+ height: block.height,
+ }
+}
+
+fn background_visible(background: &TextBackground) -> bool {
+ background.enabled && !background.color.is_empty() && background.color != "transparent"
+}
+
+pub fn text_background_rect(
+ text_align: TextAlign,
+ block: &TextBlockMeasurement,
+ background: &TextBackground,
+ font_size_ratio: f64,
+) -> Option<Rect> {
+ if !background_visible(background) {
+ return None;
+ }
+ let rect = text_rect(text_align, block);
+ let padding_x = background.padding_x * font_size_ratio;
+ let padding_y = background.padding_y * font_size_ratio;
+ Some(Rect {
+ left: rect.left - padding_x + background.offset_x,
+ top: rect.top - padding_y + background.offset_y,
+ width: rect.width + padding_x * 2.0,
+ height: rect.height + padding_y * 2.0,
+ })
+}
+
+pub fn text_visual_rect(
+ text_align: TextAlign,
+ block: &TextBlockMeasurement,
+ background: &TextBackground,
+ font_size_ratio: f64,
+) -> Rect {
+ let rect = text_rect(text_align, block);
+ let Some(background_rect) = text_background_rect(text_align, block, background, font_size_ratio)
+ else {
+ return rect;
+ };
+ let left = rect.left.min(background_rect.left);
+ let top = rect.top.min(background_rect.top);
+ Rect {
+ left,
+ top,
+ width: rect.right().max(background_rect.right()) - left,
+ height: rect.bottom().max(background_rect.bottom()) - top,
+ }
+}
+
+pub fn decoration_rect(
+ text_decoration: TextDecoration,
+ text_align: TextAlign,
+ line_width: f64,
+ line_y: f64,
+ ascent: f64,
+ descent: f64,
+ scaled_font_size: f64,
+) -> Option<Rect> {
+ if text_decoration == TextDecoration::None {
+ return None;
+ }
+ let thickness = (scaled_font_size * TEXT_DECORATION_THICKNESS_RATIO).max(1.0);
+ let top = match text_decoration {
+ TextDecoration::Underline => line_y + descent + thickness,
+ TextDecoration::LineThrough => line_y - (ascent - descent) * STRIKETHROUGH_VERTICAL_RATIO,
+ TextDecoration::None => unreachable!(),
+ };
+ Some(Rect {
+ left: align_offset(text_align, line_width),
+ top,
+ width: line_width,
+ height: thickness,
+ })
+}
diff --git a/rust/crates/text/src/lib.rs b/rust/crates/text/src/lib.rs
index 8b137891..ccb324bf 100644
--- a/rust/crates/text/src/lib.rs
+++ b/rust/crates/text/src/lib.rs
@@ -1 +1,22 @@
+mod color;
+mod engine;
+mod layout;
+mod params;
+mod raster;
+pub use color::Color;
+pub use engine::{MeasuredTextLayout, TextEngine, TextError};
+pub use layout::{
+ LineMetrics, Rect, ResolvedTextLayout, STRIKETHROUGH_VERTICAL_RATIO,
+ TEXT_DECORATION_THICKNESS_RATIO, TextBlockMeasurement, align_offset, decoration_rect,
+ measure_text_block, resolve_text_layout, text_background_rect, text_rect, text_visual_rect,
+};
+pub use params::{
+ CORNER_RADIUS_MAX, CORNER_RADIUS_MIN, DEFAULT_BACKGROUND_COLOR,
+ DEFAULT_BACKGROUND_CORNER_RADIUS, DEFAULT_BACKGROUND_ENABLED, DEFAULT_BACKGROUND_OFFSET_X,
+ DEFAULT_BACKGROUND_OFFSET_Y, DEFAULT_BACKGROUND_PADDING_X, DEFAULT_BACKGROUND_PADDING_Y,
+ DEFAULT_LETTER_SPACING, DEFAULT_LINE_HEIGHT, DEFAULT_TEXT_COLOR, FONT_SIZE_SCALE_REFERENCE,
+ FontStyle, FontWeight, MAX_FONT_SIZE, MIN_FONT_SIZE, TextAlign, TextBackground, TextDecoration,
+ TextLayoutParams, text_background_from, text_layout_params_from,
+};
+pub use raster::{RasterOptions, RgbaBuffer, StrokeStyle, rasterize};
diff --git a/rust/crates/text/src/params.rs b/rust/crates/text/src/params.rs
new file mode 100644
index 00000000..bf2d7702
--- /dev/null
+++ b/rust/crates/text/src/params.rs
@@ -0,0 +1,166 @@
+use scene::params::{ParamValue, ParamValues};
+
+pub const MIN_FONT_SIZE: f64 = 5.0;
+pub const MAX_FONT_SIZE: f64 = 300.0;
+pub const DEFAULT_TEXT_COLOR: &str = "#000000";
+pub const FONT_SIZE_SCALE_REFERENCE: f64 = 90.0;
+pub const CORNER_RADIUS_MIN: f64 = 0.0;
+pub const CORNER_RADIUS_MAX: f64 = 100.0;
+pub const DEFAULT_LETTER_SPACING: f64 = 0.0;
+pub const DEFAULT_LINE_HEIGHT: f64 = 1.2;
+pub const DEFAULT_BACKGROUND_ENABLED: bool = false;
+pub const DEFAULT_BACKGROUND_COLOR: &str = "#000000";
+pub const DEFAULT_BACKGROUND_CORNER_RADIUS: f64 = 0.0;
+pub const DEFAULT_BACKGROUND_PADDING_X: f64 = 30.0;
+pub const DEFAULT_BACKGROUND_PADDING_Y: f64 = 42.0;
+pub const DEFAULT_BACKGROUND_OFFSET_X: f64 = 0.0;
+pub const DEFAULT_BACKGROUND_OFFSET_Y: f64 = 0.0;
+
+#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
+pub enum TextAlign {
+ Left,
+ #[default]
+ Center,
+ Right,
+}
+
+#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
+pub enum FontWeight {
+ #[default]
+ Normal,
+ Bold,
+}
+
+#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
+pub enum FontStyle {
+ #[default]
+ Normal,
+ Italic,
+}
+
+#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
+pub enum TextDecoration {
+ #[default]
+ None,
+ Underline,
+ LineThrough,
+}
+
+#[derive(Clone, Debug, PartialEq)]
+pub struct TextLayoutParams {
+ pub content: String,
+ pub font_size: f64,
+ pub font_family: String,
+ pub font_weight: FontWeight,
+ pub font_style: FontStyle,
+ pub text_align: TextAlign,
+ pub text_decoration: TextDecoration,
+ pub letter_spacing: f64,
+ pub line_height: f64,
+}
+
+impl Default for TextLayoutParams {
+ fn default() -> Self {
+ Self {
+ content: "Default text".to_string(),
+ font_size: 15.0,
+ font_family: "Arial".to_string(),
+ font_weight: FontWeight::Normal,
+ font_style: FontStyle::Normal,
+ text_align: TextAlign::Center,
+ text_decoration: TextDecoration::None,
+ letter_spacing: DEFAULT_LETTER_SPACING,
+ line_height: DEFAULT_LINE_HEIGHT,
+ }
+ }
+}
+
+#[derive(Clone, Debug, PartialEq)]
+pub struct TextBackground {
+ pub enabled: bool,
+ pub color: String,
+ pub corner_radius: f64,
+ pub padding_x: f64,
+ pub padding_y: f64,
+ pub offset_x: f64,
+ pub offset_y: f64,
+}
+
+impl Default for TextBackground {
+ fn default() -> Self {
+ Self {
+ enabled: DEFAULT_BACKGROUND_ENABLED,
+ color: DEFAULT_BACKGROUND_COLOR.to_string(),
+ corner_radius: DEFAULT_BACKGROUND_CORNER_RADIUS,
+ padding_x: DEFAULT_BACKGROUND_PADDING_X,
+ padding_y: DEFAULT_BACKGROUND_PADDING_Y,
+ offset_x: DEFAULT_BACKGROUND_OFFSET_X,
+ offset_y: DEFAULT_BACKGROUND_OFFSET_Y,
+ }
+ }
+}
+
+pub fn text_layout_params_from(params: &ParamValues) -> TextLayoutParams {
+ TextLayoutParams {
+ content: string_param(params, "content", "Default text"),
+ font_size: number_param(params, "fontSize", 15.0),
+ font_family: string_param(params, "fontFamily", "Arial"),
+ font_weight: match string_param(params, "fontWeight", "normal").as_str() {
+ "bold" => FontWeight::Bold,
+ _ => FontWeight::Normal,
+ },
+ font_style: match string_param(params, "fontStyle", "normal").as_str() {
+ "italic" => FontStyle::Italic,
+ _ => FontStyle::Normal,
+ },
+ text_align: match string_param(params, "textAlign", "center").as_str() {
+ "left" => TextAlign::Left,
+ "right" => TextAlign::Right,
+ _ => TextAlign::Center,
+ },
+ text_decoration: match string_param(params, "textDecoration", "none").as_str() {
+ "underline" => TextDecoration::Underline,
+ "line-through" => TextDecoration::LineThrough,
+ _ => TextDecoration::None,
+ },
+ letter_spacing: number_param(params, "letterSpacing", DEFAULT_LETTER_SPACING),
+ line_height: number_param(params, "lineHeight", DEFAULT_LINE_HEIGHT),
+ }
+}
+
+pub fn text_background_from(params: &ParamValues) -> TextBackground {
+ TextBackground {
+ enabled: bool_param(params, "background.enabled", DEFAULT_BACKGROUND_ENABLED),
+ color: string_param(params, "background.color", DEFAULT_BACKGROUND_COLOR),
+ corner_radius: number_param(
+ params,
+ "background.cornerRadius",
+ DEFAULT_BACKGROUND_CORNER_RADIUS,
+ ),
+ padding_x: number_param(params, "background.paddingX", DEFAULT_BACKGROUND_PADDING_X),
+ padding_y: number_param(params, "background.paddingY", DEFAULT_BACKGROUND_PADDING_Y),
+ offset_x: number_param(params, "background.offsetX", DEFAULT_BACKGROUND_OFFSET_X),
+ offset_y: number_param(params, "background.offsetY", DEFAULT_BACKGROUND_OFFSET_Y),
+ }
+}
+
+fn string_param(params: &ParamValues, key: &str, fallback: &str) -> String {
+ match params.get(key) {
+ Some(ParamValue::String(value)) => value.clone(),
+ _ => fallback.to_string(),
+ }
+}
+
+fn number_param(params: &ParamValues, key: &str, fallback: f64) -> f64 {
+ match params.get(key) {
+ Some(ParamValue::Number(value)) => *value,
+ _ => fallback,
+ }
+}
+
+fn bool_param(params: &ParamValues, key: &str, fallback: bool) -> bool {
+ match params.get(key) {
+ Some(ParamValue::Bool(value)) => *value,
+ _ => fallback,
+ }
+}
diff --git a/rust/crates/text/src/raster.rs b/rust/crates/text/src/raster.rs
new file mode 100644
index 00000000..de945696
--- /dev/null
+++ b/rust/crates/text/src/raster.rs
@@ -0,0 +1,265 @@
+use crate::color::Color;
+use crate::engine::{MeasuredTextLayout, TextEngine};
+use crate::layout::{align_offset, decoration_rect, text_background_rect, text_visual_rect};
+use crate::params::TextBackground;
+
+#[derive(Clone, Debug, PartialEq)]
+pub struct RgbaBuffer {
+ pub width: u32,
+ pub height: u32,
+ pub pixels: Vec<u8>,
+}
+
+impl RgbaBuffer {
+ fn new(width: u32, height: u32) -> Self {
+ Self {
+ width,
+ height,
+ pixels: vec![0; width as usize * height as usize * 4],
+ }
+ }
+
+ pub fn pixel(&self, x: u32, y: u32) -> Color {
+ let index = (y as usize * self.width as usize + x as usize) * 4;
+ Color::rgba(
+ self.pixels[index],
+ self.pixels[index + 1],
+ self.pixels[index + 2],
+ self.pixels[index + 3],
+ )
+ }
+
+ fn blend_pixel(&mut self, x: i32, y: i32, color: Color) {
+ if x < 0 || y < 0 || x >= self.width as i32 || y >= self.height as i32 || color.a == 0 {
+ return;
+ }
+ let index = (y as usize * self.width as usize + x as usize) * 4;
+ let src_alpha = u32::from(color.a);
+ let dst_alpha = u32::from(self.pixels[index + 3]);
+ let out_alpha = src_alpha + dst_alpha * (255 - src_alpha) / 255;
+ if out_alpha == 0 {
+ return;
+ }
+ for (channel, src) in [(0, color.r), (1, color.g), (2, color.b)] {
+ let dst = u32::from(self.pixels[index + channel]);
+ let blended = (u32::from(src) * src_alpha * 255
+ + dst * dst_alpha * (255 - src_alpha))
+ / (out_alpha * 255);
+ self.pixels[index + channel] = blended as u8;
+ }
+ self.pixels[index + 3] = out_alpha as u8;
+ }
+}
+
+#[derive(Clone, Copy, Debug, PartialEq)]
+pub struct StrokeStyle {
+ pub color: Color,
+ pub width: f64,
+}
+
+#[derive(Clone, Copy, Debug)]
+pub struct RasterOptions<'a> {
+ pub text_color: Color,
+ pub background: Option<(&'a TextBackground, Color)>,
+ pub stroke: Option<StrokeStyle>,
+}
+
+pub fn rasterize(
+ engine: &mut TextEngine,
+ measured: &MeasuredTextLayout,
+ options: &RasterOptions,
+) -> RgbaBuffer {
+ let resolved = &measured.resolved;
+ let disabled_background = TextBackground::default();
+ let rect_background = options
+ .background
+ .map(|(background, _)| background)
+ .unwrap_or(&disabled_background);
+ let visual = text_visual_rect(
+ resolved.text_align,
+ &measured.block,
+ rect_background,
+ resolved.font_size_ratio,
+ );
+ let width = visual.width.ceil().max(1.0) as u32;
+ let height = visual.height.ceil().max(1.0) as u32;
+ let mut output = RgbaBuffer::new(width, height);
+ let origin_x = -visual.left;
+ let origin_y = -visual.top;
+
+ if let Some((background, color)) = options.background {
+ if color.a > 0 {
+ if let Some(rect) = text_background_rect(
+ resolved.text_align,
+ &measured.block,
+ background,
+ resolved.font_size_ratio,
+ ) {
+ let radius = (background.corner_radius.clamp(
+ crate::params::CORNER_RADIUS_MIN,
+ crate::params::CORNER_RADIUS_MAX,
+ ) / 100.0)
+ * (rect.width.min(rect.height) / 2.0);
+ fill_rounded_rect(
+ &mut output,
+ origin_x + rect.left,
+ origin_y + rect.top,
+ rect.width,
+ rect.height,
+ radius,
+ color,
+ );
+ }
+ }
+ }
+
+ if options.text_color.a > 0 || options.stroke.is_some_and(|stroke| stroke.color.a > 0) {
+ draw_glyphs(engine, measured, &mut output, origin_x, origin_y, options);
+ }
+
+ if options.text_color.a > 0 {
+ for (index, metrics) in measured.line_metrics.iter().enumerate() {
+ let line_y =
+ index as f64 * resolved.line_height_px - measured.block.visual_center_offset;
+ if let Some(rect) = decoration_rect(
+ resolved.text_decoration,
+ resolved.text_align,
+ metrics.width,
+ line_y,
+ metrics.ascent,
+ metrics.descent,
+ resolved.scaled_font_size,
+ ) {
+ fill_rect(
+ &mut output,
+ origin_x + rect.left,
+ origin_y + rect.top,
+ rect.width,
+ rect.height,
+ options.text_color,
+ );
+ }
+ }
+ }
+
+ output
+}
+
+fn draw_glyphs(
+ engine: &mut TextEngine,
+ measured: &MeasuredTextLayout,
+ output: &mut RgbaBuffer,
+ origin_x: f64,
+ origin_y: f64,
+ options: &RasterOptions,
+) {
+ let resolved = &measured.resolved;
+ let y_shift = origin_y - measured.block.height / 2.0;
+ for run in measured.buffer.layout_runs() {
+ let raw_width = run
+ .glyphs
+ .iter()
+ .fold(0.0_f32, |max, glyph| max.max(glyph.x + glyph.w));
+ let line_width = (f64::from(raw_width) - resolved.letter_spacing).max(0.0);
+ let x_offset = origin_x + align_offset(resolved.text_align, line_width);
+ for glyph in run.glyphs {
+ let physical = glyph.physical((x_offset as f32, y_shift as f32), 1.0);
+ let baseline = run.line_y as i32;
+ if let Some(stroke) = options.stroke {
+ if stroke.color.a > 0 && stroke.width > 0.0 {
+ let cosmic = stroke.color.to_cosmic();
+ let offsets = stroke_offsets(stroke.width / 2.0);
+ engine.swash_cache.with_pixels(
+ &mut engine.font_system,
+ physical.cache_key,
+ cosmic,
+ |x, y, color| {
+ for (dx, dy) in &offsets {
+ output.blend_pixel(
+ physical.x + x + dx,
+ baseline + physical.y + y + dy,
+ Color::from_cosmic(color),
+ );
+ }
+ },
+ );
+ }
+ }
+ if options.text_color.a > 0 {
+ let cosmic = options.text_color.to_cosmic();
+ engine.swash_cache.with_pixels(
+ &mut engine.font_system,
+ physical.cache_key,
+ cosmic,
+ |x, y, color| {
+ output.blend_pixel(
+ physical.x + x,
+ baseline + physical.y + y,
+ Color::from_cosmic(color),
+ );
+ },
+ );
+ }
+ }
+ }
+}
+
+fn stroke_offsets(radius: f64) -> Vec<(i32, i32)> {
+ let mut offsets = Vec::new();
+ if radius < 0.5 {
+ offsets.push((0, 0));
+ return offsets;
+ }
+ let steps = ((radius * 6.0).ceil() as u32).max(16);
+ for step in 0..steps {
+ let angle = std::f64::consts::TAU * f64::from(step) / f64::from(steps);
+ let dx = (radius * angle.cos()).round() as i32;
+ let dy = (radius * angle.sin()).round() as i32;
+ if !offsets.contains(&(dx, dy)) {
+ offsets.push((dx, dy));
+ }
+ }
+ offsets
+}
+
+fn fill_rect(output: &mut RgbaBuffer, left: f64, top: f64, width: f64, height: f64, color: Color) {
+ let x_start = left.round() as i32;
+ let y_start = top.round() as i32;
+ let x_end = (left + width).round() as i32;
+ let y_end = (top + height).round() as i32;
+ for y in y_start..y_end {
+ for x in x_start..x_end {
+ output.blend_pixel(x, y, color);
+ }
+ }
+}
+
+fn fill_rounded_rect(
+ output: &mut RgbaBuffer,
+ left: f64,
+ top: f64,
+ width: f64,
+ height: f64,
+ radius: f64,
+ color: Color) {
+ let radius = radius.clamp(0.0, width.min(height) / 2.0);
+ let center_x = left + width / 2.0;
+ let center_y = top + height / 2.0;
+ let inner_x = (width / 2.0 - radius).max(0.0);
+ let inner_y = (height / 2.0 - radius).max(0.0);
+ let x_start = left.floor() as i32;
+ let y_start = top.floor() as i32;
+ let x_end = (left + width).ceil() as i32;
+ let y_end = (top + height).ceil() as i32;
+ for y in y_start..y_end {
+ for x in x_start..x_end {
+ let qx = (f64::from(x) + 0.5 - center_x).abs() - inner_x;
+ let qy = (f64::from(y) + 0.5 - center_y).abs() - inner_y;
+ let outside = qx.max(0.0).hypot(qy.max(0.0)) + qx.max(qy).min(0.0) - radius;
+ let coverage = (0.5 - outside).clamp(0.0, 1.0);
+ if coverage > 0.0 {
+ output.blend_pixel(x, y, color.scaled_alpha(coverage));
+ }
+ }
+ }
+}
diff --git a/rust/crates/text/tests/fixtures/DejaVuSans-Bold.ttf b/rust/crates/text/tests/fixtures/DejaVuSans-Bold.ttf
new file mode 100644
index 00000000..3710f897
--- /dev/null
+++ b/rust/crates/text/tests/fixtures/DejaVuSans-Bold.ttf
Binary files differ
diff --git a/rust/crates/text/tests/fixtures/DejaVuSans.ttf b/rust/crates/text/tests/fixtures/DejaVuSans.ttf
new file mode 100644
index 00000000..1a126069
--- /dev/null
+++ b/rust/crates/text/tests/fixtures/DejaVuSans.ttf
Binary files differ
diff --git a/rust/crates/text/tests/layout.rs b/rust/crates/text/tests/layout.rs
new file mode 100644
index 00000000..c0d652a5
--- /dev/null
+++ b/rust/crates/text/tests/layout.rs
@@ -0,0 +1,370 @@
+use scene::params::{ParamValue, ParamValues};
+use text::{
+ Color, FontStyle, FontWeight, TextAlign, TextBackground, TextDecoration, TextEngine,
+ TextLayoutParams, align_offset, decoration_rect, measure_text_block, resolve_text_layout,
+ text_background_from, text_background_rect, text_layout_params_from, text_rect,
+ text_visual_rect,
+};
+
+const FONT_REGULAR: &[u8] = include_bytes!("fixtures/DejaVuSans.ttf");
+const FONT_BOLD: &[u8] = include_bytes!("fixtures/DejaVuSans-Bold.ttf");
+
+fn engine() -> TextEngine {
+ TextEngine::with_font_bytes(&[FONT_REGULAR, FONT_BOLD])
+}
+
+fn dejavu_params(content: &str) -> TextLayoutParams {
+ TextLayoutParams {
+ content: content.to_string(),
+ font_family: "DejaVu Sans".to_string(),
+ ..TextLayoutParams::default()
+ }
+}
+
+#[test]
+fn params_fall_back_to_web_defaults() {
+ let params = ParamValues::new();
+ let layout = text_layout_params_from(&params);
+ assert_eq!(layout, TextLayoutParams::default());
+ assert_eq!(layout.content, "Default text");
+ assert_eq!(layout.font_size, 15.0);
+ assert_eq!(layout.font_family, "Arial");
+ assert_eq!(layout.text_align, TextAlign::Center);
+ assert_eq!(layout.text_decoration, TextDecoration::None);
+ assert_eq!(layout.letter_spacing, 0.0);
+ assert_eq!(layout.line_height, 1.2);
+
+ let background = text_background_from(&params);
+ assert_eq!(background, TextBackground::default());
+ assert!(!background.enabled);
+ assert_eq!(background.color, "#000000");
+ assert_eq!(background.padding_x, 30.0);
+ assert_eq!(background.padding_y, 42.0);
+}
+
+#[test]
+fn params_read_typed_values_and_reject_wrong_types() {
+ let mut params = ParamValues::new();
+ params.insert("content".into(), ParamValue::String("Hello".into()));
+ params.insert("fontSize".into(), ParamValue::Number(24.0));
+ params.insert("fontFamily".into(), ParamValue::String("Inter".into()));
+ params.insert("fontWeight".into(), ParamValue::String("bold".into()));
+ params.insert("fontStyle".into(), ParamValue::String("italic".into()));
+ params.insert("textAlign".into(), ParamValue::String("right".into()));
+ params.insert(
+ "textDecoration".into(),
+ ParamValue::String("line-through".into()),
+ );
+ params.insert("letterSpacing".into(), ParamValue::Number(2.5));
+ params.insert("lineHeight".into(), ParamValue::Number(1.5));
+ params.insert("background.enabled".into(), ParamValue::Bool(true));
+ params.insert(
+ "background.color".into(),
+ ParamValue::String("#ff0000".into()),
+ );
+ params.insert("background.cornerRadius".into(), ParamValue::Number(50.0));
+ params.insert("background.paddingX".into(), ParamValue::Number(8.0));
+ params.insert("background.paddingY".into(), ParamValue::Number(6.0));
+ params.insert("background.offsetX".into(), ParamValue::Number(-4.0));
+ params.insert("background.offsetY".into(), ParamValue::Number(3.0));
+ params.insert("opacity".into(), ParamValue::String("not-a-number".into()));
+
+ let layout = text_layout_params_from(&params);
+ assert_eq!(layout.content, "Hello");
+ assert_eq!(layout.font_size, 24.0);
+ assert_eq!(layout.font_family, "Inter");
+ assert_eq!(layout.font_weight, FontWeight::Bold);
+ assert_eq!(layout.font_style, FontStyle::Italic);
+ assert_eq!(layout.text_align, TextAlign::Right);
+ assert_eq!(layout.text_decoration, TextDecoration::LineThrough);
+ assert_eq!(layout.letter_spacing, 2.5);
+ assert_eq!(layout.line_height, 1.5);
+
+ let background = text_background_from(&params);
+ assert!(background.enabled);
+ assert_eq!(background.color, "#ff0000");
+ assert_eq!(background.corner_radius, 50.0);
+ assert_eq!(background.padding_x, 8.0);
+ assert_eq!(background.padding_y, 6.0);
+ assert_eq!(background.offset_x, -4.0);
+ assert_eq!(background.offset_y, 3.0);
+}
+
+#[test]
+fn params_ignore_mismatched_value_types() {
+ let mut params = ParamValues::new();
+ params.insert("content".into(), ParamValue::Number(42.0));
+ params.insert("fontSize".into(), ParamValue::String("big".into()));
+ params.insert("fontWeight".into(), ParamValue::String("heavy".into()));
+ params.insert("textAlign".into(), ParamValue::String("justify".into()));
+ params.insert("background.enabled".into(), ParamValue::String("yes".into()));
+
+ let layout = text_layout_params_from(&params);
+ assert_eq!(layout.content, "Default text");
+ assert_eq!(layout.font_size, 15.0);
+ assert_eq!(layout.font_weight, FontWeight::Normal);
+ assert_eq!(layout.text_align, TextAlign::Center);
+ assert!(!text_background_from(&params).enabled);
+}
+
+#[test]
+fn resolve_scales_font_size_with_canvas_height() {
+ let params = dejavu_params("Hello");
+ let resolved = resolve_text_layout(&params, 90.0);
+ assert_eq!(resolved.scaled_font_size, 15.0);
+ assert_eq!(resolved.line_height_px, 18.0);
+ assert_eq!(resolved.font_size_ratio, 1.0);
+
+ let doubled = resolve_text_layout(&params, 180.0);
+ assert_eq!(doubled.scaled_font_size, 30.0);
+ assert_eq!(doubled.line_height_px, 36.0);
+}
+
+#[test]
+fn measure_splits_explicit_lines_and_sizes_block() {
+ let mut engine = engine();
+ let params = dejavu_params("ab\ncdef\ng");
+ let measured = engine.measure(&params, 90.0);
+
+ assert_eq!(measured.lines, vec!["ab", "cdef", "g"]);
+ assert_eq!(measured.line_metrics.len(), 3);
+ assert_eq!(measured.block.height, 54.0);
+ assert_eq!(measured.block.visual_center_offset, 18.0);
+
+ let widest = measured.line_metrics[1].width;
+ assert_eq!(measured.block.max_width, widest);
+ assert!(widest > measured.line_metrics[0].width);
+ assert!(measured.line_metrics[0].width > measured.line_metrics[2].width);
+ assert!(measured.line_metrics[2].width > 0.0);
+}
+
+#[test]
+fn measure_trailing_empty_line_keeps_block_height() {
+ let mut engine = engine();
+ let params = dejavu_params("ab\n");
+ let measured = engine.measure(&params, 90.0);
+
+ assert_eq!(measured.lines.len(), 2);
+ assert_eq!(measured.block.height, 36.0);
+ assert_eq!(measured.line_metrics[1].width, 0.0);
+ assert_eq!(
+ measured.line_metrics[1].ascent,
+ measured.resolved.scaled_font_size * 0.8
+ );
+ assert_eq!(
+ measured.line_metrics[1].descent,
+ measured.resolved.scaled_font_size * 0.2
+ );
+}
+
+#[test]
+fn wrap_width_controls_visual_line_count() {
+ let mut engine = engine();
+ let long_word = "a".repeat(30);
+ let params = dejavu_params(&long_word);
+
+ let unwrapped = engine.measure(&params, 90.0);
+ assert_eq!(unwrapped.lines.len(), 1);
+
+ let ten_a = dejavu_params(&"a".repeat(10));
+ let ten_width = engine.measure(&ten_a, 90.0).line_metrics[0].width;
+ let wrapped = engine.measure_with_wrap(&params, 90.0, Some(ten_width + 1.0));
+ assert_eq!(wrapped.lines.len(), 3);
+ assert_eq!(wrapped.block.height, 3.0 * wrapped.resolved.line_height_px);
+ for metrics in &wrapped.line_metrics {
+ assert!(metrics.width <= ten_width + 1.0);
+ }
+}
+
+#[test]
+fn line_height_scales_block_height() {
+ let mut engine = engine();
+ let single = dejavu_params("ab\ncd");
+ let relaxed = TextLayoutParams {
+ line_height: 2.4,
+ ..single.clone()
+ };
+
+ let tight_layout = engine.measure(&single, 90.0);
+ let relaxed_layout = engine.measure(&relaxed, 90.0);
+ assert_eq!(relaxed_layout.block.height, tight_layout.block.height * 2.0);
+ assert_eq!(
+ relaxed_layout.block.visual_center_offset,
+ tight_layout.block.visual_center_offset * 2.0
+ );
+}
+
+#[test]
+fn letter_spacing_widens_lines_monotonically() {
+ let mut engine = engine();
+ let content = "a".repeat(10);
+ let width_at = |engine: &mut TextEngine, spacing: f64| {
+ let params = TextLayoutParams {
+ content: content.clone(),
+ font_family: "DejaVu Sans".to_string(),
+ letter_spacing: spacing,
+ ..TextLayoutParams::default()
+ };
+ engine.measure(&params, 90.0).line_metrics[0].width
+ };
+
+ let zero = width_at(&mut engine, 0.0);
+ let five = width_at(&mut engine, 5.0);
+ let ten = width_at(&mut engine, 10.0);
+ assert!(five > zero);
+ assert!(ten > five);
+ assert!((ten - zero - 90.0).abs() < 0.5);
+}
+
+#[test]
+fn block_measurement_matches_web_formulas() {
+ let metrics = [
+ text::LineMetrics {
+ width: 40.0,
+ ascent: 12.0,
+ descent: 3.0,
+ },
+ text::LineMetrics {
+ width: 75.0,
+ ascent: 12.0,
+ descent: 3.0,
+ },
+ ];
+ let block = measure_text_block(&metrics, 20.0);
+ assert_eq!(block.max_width, 75.0);
+ assert_eq!(block.height, 40.0);
+ assert_eq!(block.visual_center_offset, 10.0);
+
+ assert_eq!(align_offset(TextAlign::Left, 50.0), 0.0);
+ assert_eq!(align_offset(TextAlign::Center, 50.0), -25.0);
+ assert_eq!(align_offset(TextAlign::Right, 50.0), -50.0);
+}
+
+#[test]
+fn background_rect_matches_hand_computed_rect() {
+ let mut engine = engine();
+ let params = TextLayoutParams {
+ text_align: TextAlign::Left,
+ ..dejavu_params("abcd")
+ };
+ let measured = engine.measure(&params, 90.0);
+ let block = measured.block;
+ let width = block.max_width;
+
+ let background = TextBackground {
+ enabled: true,
+ color: "#ff0000".to_string(),
+ corner_radius: 0.0,
+ padding_x: 10.0,
+ padding_y: 5.0,
+ offset_x: 3.0,
+ offset_y: -2.0,
+ };
+
+ let rect = text_background_rect(TextAlign::Left, &block, &background, 1.0)
+ .expect("visible background");
+ assert_eq!(rect.left, -7.0);
+ assert_eq!(rect.top, -16.0);
+ assert_eq!(rect.width, width + 20.0);
+ assert_eq!(rect.height, 28.0);
+
+ let centered = text_background_rect(TextAlign::Center, &block, &background, 1.0)
+ .expect("visible background");
+ assert_eq!(centered.left, -width / 2.0 - 7.0);
+ assert_eq!(centered.width, width + 20.0);
+
+ let scaled = text_background_rect(TextAlign::Left, &block, &background, 2.0)
+ .expect("visible background");
+ assert_eq!(scaled.left, -17.0);
+ assert_eq!(scaled.width, width + 40.0);
+ assert_eq!(scaled.height, 38.0);
+}
+
+#[test]
+fn visual_rect_unions_text_and_background() {
+ let mut engine = engine();
+ let params = dejavu_params("abcd");
+ let measured = engine.measure(&params, 90.0);
+ let block = measured.block;
+
+ let disabled = TextBackground::default();
+ let visual = text_visual_rect(TextAlign::Center, &block, &disabled, 1.0);
+ assert_eq!(visual, text_rect(TextAlign::Center, &block));
+
+ let background = TextBackground {
+ enabled: true,
+ padding_x: 10.0,
+ padding_y: 5.0,
+ ..TextBackground::default()
+ };
+ let visual = text_visual_rect(TextAlign::Center, &block, &background, 1.0);
+ assert_eq!(visual.width, block.max_width + 20.0);
+ assert_eq!(visual.height, block.height + 10.0);
+ assert_eq!(visual.left, -block.max_width / 2.0 - 10.0);
+ assert_eq!(visual.top, -block.height / 2.0 - 5.0);
+
+ let invisible = TextBackground {
+ enabled: true,
+ color: "transparent".to_string(),
+ ..TextBackground::default()
+ };
+ assert_eq!(
+ text_background_rect(TextAlign::Center, &block, &invisible, 1.0),
+ None
+ );
+}
+
+#[test]
+fn decoration_rect_matches_web_geometry() {
+ let underline = decoration_rect(
+ TextDecoration::Underline,
+ TextAlign::Left,
+ 100.0,
+ 0.0,
+ 12.0,
+ 3.0,
+ 20.0,
+ )
+ .expect("underline");
+ assert_eq!(underline.left, 0.0);
+ assert_eq!(underline.width, 100.0);
+ assert!((underline.height - 1.4).abs() < 1e-9);
+ assert!((underline.top - 4.4).abs() < 1e-9);
+
+ let strike = decoration_rect(
+ TextDecoration::LineThrough,
+ TextAlign::Center,
+ 100.0,
+ 0.0,
+ 12.0,
+ 3.0,
+ 20.0,
+ )
+ .expect("line-through");
+ assert_eq!(strike.left, -50.0);
+ assert!((strike.top - -3.15).abs() < 1e-9);
+ assert!((strike.height - 1.4).abs() < 1e-9);
+
+ let tiny = decoration_rect(TextDecoration::Underline, TextAlign::Right, 50.0, 0.0, 8.0, 2.0, 5.0)
+ .expect("underline");
+ assert_eq!(tiny.height, 1.0);
+ assert_eq!(tiny.left, -50.0);
+
+ assert_eq!(
+ decoration_rect(TextDecoration::None, TextAlign::Left, 100.0, 0.0, 12.0, 3.0, 20.0),
+ None
+ );
+}
+
+#[test]
+fn color_parses_web_hex_formats() {
+ assert_eq!(Color::parse("#ff0000"), Some(Color::rgb(255, 0, 0)));
+ assert_eq!(Color::parse("#f00"), Some(Color::rgb(255, 0, 0)));
+ assert_eq!(
+ Color::parse("#ff000080"),
+ Some(Color::rgba(255, 0, 0, 128))
+ );
+ assert_eq!(Color::parse("transparent"), Some(Color::TRANSPARENT));
+ assert_eq!(Color::parse("red"), None);
+ assert_eq!(Color::parse(""), None);
+}
diff --git a/rust/crates/text/tests/raster.rs b/rust/crates/text/tests/raster.rs
new file mode 100644
index 00000000..2e6921a5
--- /dev/null
+++ b/rust/crates/text/tests/raster.rs
@@ -0,0 +1,172 @@
+use text::{
+ Color, RasterOptions, StrokeStyle, TextAlign, TextBackground, TextEngine, TextLayoutParams,
+ rasterize, text_visual_rect,
+};
+
+const FONT_REGULAR: &[u8] = include_bytes!("fixtures/DejaVuSans.ttf");
+const FONT_BOLD: &[u8] = include_bytes!("fixtures/DejaVuSans-Bold.ttf");
+
+fn engine() -> TextEngine {
+ TextEngine::with_font_bytes(&[FONT_REGULAR, FONT_BOLD])
+}
+
+fn params(content: &str) -> TextLayoutParams {
+ TextLayoutParams {
+ content: content.to_string(),
+ font_family: "DejaVu Sans".to_string(),
+ ..TextLayoutParams::default()
+ }
+}
+
+fn opaque_pixels(buffer: &text::RgbaBuffer) -> usize {
+ buffer.pixels.chunks(4).filter(|px| px[3] > 0).count()
+}
+
+#[test]
+fn raster_produces_non_empty_output_with_visual_rect_dimensions() {
+ let mut engine = engine();
+ let measured = engine.measure(&params("Hello"), 90.0);
+ let visual = text_visual_rect(
+ TextAlign::Center,
+ &measured.block,
+ &TextBackground::default(),
+ 1.0,
+ );
+ let buffer = rasterize(
+ &mut engine,
+ &measured,
+ &RasterOptions {
+ text_color: Color::rgb(255, 255, 255),
+ background: None,
+ stroke: None,
+ },
+ );
+
+ assert_eq!(buffer.width, visual.width.ceil() as u32);
+ assert_eq!(buffer.height, visual.height.ceil() as u32);
+ assert_eq!(buffer.pixels.len(), buffer.width as usize * buffer.height as usize * 4);
+ assert!(opaque_pixels(&buffer) > 0);
+ assert_eq!(buffer.pixel(0, 0).a, 0);
+}
+
+#[test]
+fn raster_fills_background_inside_padded_box() {
+ let mut engine = engine();
+ let measured = engine.measure(&params("Hello"), 90.0);
+ let background = TextBackground {
+ enabled: true,
+ color: "#ff0000".to_string(),
+ ..TextBackground::default()
+ };
+ let red = Color::rgb(255, 0, 0);
+ let buffer = rasterize(
+ &mut engine,
+ &measured,
+ &RasterOptions {
+ text_color: Color::rgb(255, 255, 255),
+ background: Some((&background, red)),
+ stroke: None,
+ },
+ );
+
+ assert_eq!(buffer.height, 102);
+ let corner = buffer.pixel(2, 2);
+ assert!(corner.a > 200);
+ assert!(corner.r > 200);
+ assert!(corner.g < 60);
+ assert!(corner.b < 60);
+}
+
+#[test]
+fn raster_dimensions_grow_with_background_padding() {
+ let mut engine = engine();
+ let measured = engine.measure(&params("Hello"), 90.0);
+ let plain = rasterize(
+ &mut engine,
+ &measured,
+ &RasterOptions {
+ text_color: Color::rgb(255, 255, 255),
+ background: None,
+ stroke: None,
+ },
+ );
+ let background = TextBackground {
+ enabled: true,
+ color: "#ff0000".to_string(),
+ ..TextBackground::default()
+ };
+ let padded = rasterize(
+ &mut engine,
+ &measured,
+ &RasterOptions {
+ text_color: Color::rgb(255, 255, 255),
+ background: Some((&background, Color::rgb(255, 0, 0))),
+ stroke: None,
+ },
+ );
+
+ assert_eq!(padded.width, plain.width + 60);
+ assert_eq!(padded.height, plain.height + 84);
+}
+
+#[test]
+fn raster_underline_adds_pixels_below_text() {
+ let mut engine = engine();
+ let plain_params = params("Hello");
+ let plain = engine.measure(&plain_params, 90.0);
+ let plain_buffer = rasterize(
+ &mut engine,
+ &plain,
+ &RasterOptions {
+ text_color: Color::rgb(255, 255, 255),
+ background: None,
+ stroke: None,
+ },
+ );
+
+ let underlined_params = TextLayoutParams {
+ text_decoration: text::TextDecoration::Underline,
+ ..params("Hello")
+ };
+ let underlined = engine.measure(&underlined_params, 90.0);
+ let underlined_buffer = rasterize(
+ &mut engine,
+ &underlined,
+ &RasterOptions {
+ text_color: Color::rgb(255, 255, 255),
+ background: None,
+ stroke: None,
+ },
+ );
+
+ assert!(opaque_pixels(&underlined_buffer) > opaque_pixels(&plain_buffer));
+}
+
+#[test]
+fn raster_stroke_widens_glyph_coverage() {
+ let mut engine = engine();
+ let measured = engine.measure(&params("Hello"), 180.0);
+ let fill_only = rasterize(
+ &mut engine,
+ &measured,
+ &RasterOptions {
+ text_color: Color::rgb(255, 255, 255),
+ background: None,
+ stroke: None,
+ },
+ );
+ let stroked = rasterize(
+ &mut engine,
+ &measured,
+ &RasterOptions {
+ text_color: Color::rgb(255, 255, 255),
+ background: None,
+ stroke: Some(StrokeStyle {
+ color: Color::rgb(0, 0, 0),
+ width: 4.0,
+ }),
+ },
+ );
+
+ assert!(opaque_pixels(&stroked) > opaque_pixels(&fill_only));
+}
diff --git a/rust/crates/timeline/src/placement/apply.rs b/rust/crates/timeline/src/placement/apply.rs
index 4e044a68..56c3fd0a 100644
--- a/rust/crates/timeline/src/placement/apply.rs
+++ b/rust/crates/timeline/src/placement/apply.rs
@@ -46,8 +46,8 @@ pub fn apply_placement(params: &ApplyPlacementParams) -> Option<PlacementApplied
let insert_index = params
.new_track_insert_index_override
.unwrap_or(*insert_index);
- let new_track = build_empty_track(new_track_id.clone(), *track_type, None)
- .with_elements(params.elements.clone());
+ let mut new_track = build_empty_track(new_track_id.clone(), *track_type, None);
+ *new_track.elements_mut() = params.elements.clone();
let updated_tracks = if *track_type == TrackType::Audio {
SceneTracks {
@@ -89,13 +89,3 @@ fn insert_into_audio_tracks(tracks: &SceneTracks, insert_index: usize, track: Tr
next
}
-trait WithElements {
- fn with_elements(self, elements: Vec<Element>) -> Self;
-}
-
-impl WithElements for Track {
- fn with_elements(mut self, elements: Vec<Element>) -> Self {
- *self.elements_mut() = elements;
- self
- }
-}
diff --git a/rust/crates/timeline/src/placement/mod.rs b/rust/crates/timeline/src/placement/mod.rs
index 8ad5710e..4eb2660e 100644
--- a/rust/crates/timeline/src/placement/mod.rs
+++ b/rust/crates/timeline/src/placement/mod.rs
@@ -1,4 +1,4 @@
-use scene::{Element, SceneTracks, Track};
+use scene::{SceneTracks, Track};
use time::MediaTime;
use crate::types::{ElementType, TrackType};
@@ -21,7 +21,7 @@ pub use insert_index::{
};
pub use main_track::{MAIN_TRACK_NAME, enforce_main_track_start, get_earliest_main_track_element};
pub use overlap::can_place_time_spans_on_track;
-pub use resolve::resolve_track_placement;
+pub use resolve::{ResolveTrackPlacementParams, resolve_track_placement};
pub use track_factory::build_empty_track;
#[derive(Clone, Debug, PartialEq)]
@@ -98,7 +98,3 @@ pub fn ordered_tracks(tracks: &SceneTracks) -> Vec<&Track> {
.chain(tracks.audio.iter())
.collect()
}
-
-pub fn track_elements(track: &Track) -> &[Element] {
- track.elements()
-}
diff --git a/rust/crates/timeline/tests/compute_resize.rs b/rust/crates/timeline/tests/compute_resize.rs
new file mode 100644
index 00000000..4392e0a1
--- /dev/null
+++ b/rust/crates/timeline/tests/compute_resize.rs
@@ -0,0 +1,375 @@
+use scene::{
+ BaseTimelineElement, Element, ElementRef, ImageElement, ParamValue, ParamValues, SceneTracks,
+ Track, VideoElement,
+};
+use time::{FrameRate, MediaTime};
+use timeline::resize::{
+ GroupResizeMember, GroupResizePushTarget, GroupResizeResult, GroupResizeUpdate, ResizeSide,
+ build_resize_members, compute_group_resize,
+};
+use timeline::track_element_update::update_element_in_scene_tracks;
+
+const FPS: FrameRate = FrameRate::new(30, 1);
+
+fn seconds(value: f64) -> MediaTime {
+ MediaTime::from_seconds_f64(value).unwrap()
+}
+
+fn build_push_target(element_id: &str, start_time: MediaTime, duration: MediaTime) -> GroupResizePushTarget {
+ GroupResizePushTarget {
+ track_id: "track-1".to_string(),
+ element_id: element_id.to_string(),
+ start_time,
+ duration,
+ trim_start: MediaTime::ZERO,
+ trim_end: MediaTime::ZERO,
+ }
+}
+
+fn build_static_member(
+ start_time: MediaTime,
+ duration: MediaTime,
+ left_neighbor_bound: Option<MediaTime>,
+ right_neighbor_bound: Option<MediaTime>,
+ right_push_chain: Option<Vec<GroupResizePushTarget>>,
+) -> GroupResizeMember {
+ GroupResizeMember {
+ track_id: "track-1".to_string(),
+ element_id: "element-1".to_string(),
+ start_time,
+ duration,
+ trim_start: MediaTime::ZERO,
+ trim_end: MediaTime::ZERO,
+ source_duration: None,
+ retime: None,
+ left_neighbor_bound,
+ right_neighbor_bound,
+ right_push_chain,
+ }
+}
+
+fn build_video_member(
+ start_time: MediaTime,
+ duration: MediaTime,
+ source_duration: MediaTime,
+ right_neighbor_bound: Option<MediaTime>,
+) -> GroupResizeMember {
+ GroupResizeMember {
+ track_id: "track-1".to_string(),
+ element_id: "element-1".to_string(),
+ start_time,
+ duration,
+ trim_start: MediaTime::ZERO,
+ trim_end: MediaTime::ZERO,
+ source_duration: Some(source_duration),
+ retime: None,
+ left_neighbor_bound: None,
+ right_neighbor_bound,
+ right_push_chain: None,
+ }
+}
+
+fn find_update<'a>(result: &'a GroupResizeResult, element_id: &str) -> Option<&'a GroupResizeUpdate> {
+ result
+ .updates
+ .iter()
+ .find(|update| update.element_id == element_id)
+}
+
+#[test]
+fn extends_into_the_gap_before_pushing_anything() {
+ let member = build_static_member(
+ MediaTime::ZERO,
+ seconds(5.0),
+ None,
+ Some(seconds(10.0)),
+ Some(vec![build_push_target("neighbor-1", seconds(10.0), seconds(10.0))]),
+ );
+
+ let result = compute_group_resize(&[member], ResizeSide::Right, seconds(3.0), FPS);
+
+ assert_eq!(result.delta_time, seconds(3.0));
+ assert_eq!(result.updates.len(), 1);
+ assert_eq!(
+ find_update(&result, "element-1").unwrap().patch.duration,
+ seconds(8.0)
+ );
+}
+
+#[test]
+fn pushes_the_flush_right_neighbor_by_the_overflow() {
+ let member = build_static_member(
+ MediaTime::ZERO,
+ seconds(5.0),
+ None,
+ Some(seconds(5.0)),
+ Some(vec![build_push_target("neighbor-1", seconds(5.0), seconds(10.0))]),
+ );
+
+ let result = compute_group_resize(&[member], ResizeSide::Right, seconds(3.0), FPS);
+
+ assert_eq!(result.delta_time, seconds(3.0));
+ assert_eq!(result.updates.len(), 2);
+ assert_eq!(
+ find_update(&result, "element-1").unwrap().patch.duration,
+ seconds(8.0)
+ );
+ let push = find_update(&result, "neighbor-1").unwrap();
+ assert_eq!(push.patch.start_time, seconds(8.0));
+ assert_eq!(push.patch.duration, seconds(10.0));
+ assert_eq!(push.patch.trim_start, MediaTime::ZERO);
+ assert_eq!(push.patch.trim_end, MediaTime::ZERO);
+}
+
+#[test]
+fn pushes_a_chain_of_several_neighbors_by_the_same_overflow() {
+ let member = build_static_member(
+ MediaTime::ZERO,
+ seconds(5.0),
+ None,
+ Some(seconds(5.0)),
+ Some(vec![
+ build_push_target("neighbor-1", seconds(5.0), seconds(10.0)),
+ build_push_target("neighbor-2", seconds(15.0), seconds(5.0)),
+ build_push_target("neighbor-3", seconds(25.0), seconds(2.0)),
+ ]),
+ );
+
+ let result = compute_group_resize(&[member], ResizeSide::Right, seconds(4.0), FPS);
+
+ assert_eq!(result.delta_time, seconds(4.0));
+ assert_eq!(result.updates.len(), 4);
+ assert_eq!(
+ find_update(&result, "neighbor-1").unwrap().patch.start_time,
+ seconds(9.0)
+ );
+ assert_eq!(
+ find_update(&result, "neighbor-2").unwrap().patch.start_time,
+ seconds(19.0)
+ );
+ assert_eq!(
+ find_update(&result, "neighbor-3").unwrap().patch.start_time,
+ seconds(29.0)
+ );
+}
+
+#[test]
+fn push_stops_at_the_end_of_the_track() {
+ let member = build_static_member(MediaTime::ZERO, seconds(5.0), None, None, Some(Vec::new()));
+
+ let result = compute_group_resize(&[member], ResizeSide::Right, seconds(3.0), FPS);
+
+ assert_eq!(result.delta_time, seconds(3.0));
+ assert_eq!(result.updates.len(), 1);
+ assert_eq!(
+ find_update(&result, "element-1").unwrap().patch.duration,
+ seconds(8.0)
+ );
+}
+
+#[test]
+fn video_element_clamps_at_its_right_neighbor_instead_of_pushing() {
+ let member = build_video_member(
+ MediaTime::ZERO,
+ seconds(5.0),
+ seconds(6.0),
+ Some(seconds(5.0)),
+ );
+
+ let result = compute_group_resize(&[member], ResizeSide::Right, seconds(10.0), FPS);
+
+ assert_eq!(result.delta_time, MediaTime::ZERO);
+ assert_eq!(result.updates.len(), 1);
+ assert_eq!(
+ find_update(&result, "element-1").unwrap().patch.duration,
+ seconds(5.0)
+ );
+}
+
+#[test]
+fn video_element_clamps_at_its_source_extent_when_there_is_no_neighbor() {
+ let member = build_video_member(MediaTime::ZERO, seconds(5.0), seconds(6.0), None);
+
+ let result = compute_group_resize(&[member], ResizeSide::Right, seconds(10.0), FPS);
+
+ assert_eq!(result.delta_time, seconds(1.0));
+ assert_eq!(result.updates.len(), 1);
+ assert_eq!(
+ find_update(&result, "element-1").unwrap().patch.duration,
+ seconds(6.0)
+ );
+}
+
+#[test]
+fn static_element_without_a_push_chain_keeps_the_old_neighbor_clamp() {
+ let member = build_static_member(MediaTime::ZERO, seconds(5.0), None, Some(seconds(5.0)), None);
+
+ let result = compute_group_resize(&[member], ResizeSide::Right, seconds(3.0), FPS);
+
+ assert_eq!(result.delta_time, MediaTime::ZERO);
+ assert_eq!(result.updates.len(), 1);
+}
+
+#[test]
+fn shortening_still_respects_the_one_frame_minimum_duration() {
+ let one_frame = MediaTime::from_ticks((time::TICKS_PER_SECOND as f64 / 30.0).round() as i64);
+ let member = build_static_member(MediaTime::ZERO, seconds(5.0), None, None, Some(Vec::new()));
+
+ let result = compute_group_resize(&[member], ResizeSide::Right, seconds(-10.0), FPS);
+
+ assert_eq!(result.updates.len(), 1);
+ assert_eq!(
+ find_update(&result, "element-1").unwrap().patch.duration,
+ one_frame
+ );
+}
+
+#[test]
+fn left_edge_resize_still_clamps_at_the_left_neighbor_and_pushes_nothing() {
+ let member = build_static_member(
+ seconds(5.0),
+ seconds(5.0),
+ Some(seconds(2.0)),
+ None,
+ Some(vec![build_push_target("neighbor-1", seconds(10.0), seconds(10.0))]),
+ );
+
+ let result = compute_group_resize(&[member], ResizeSide::Left, seconds(-10.0), FPS);
+
+ assert_eq!(result.delta_time, seconds(-3.0));
+ assert_eq!(result.updates.len(), 1);
+ let update = find_update(&result, "element-1").unwrap();
+ assert_eq!(update.patch.start_time, seconds(2.0));
+ assert_eq!(update.patch.duration, seconds(8.0));
+}
+
+fn visual_params() -> ParamValues {
+ ParamValues::from([
+ ("transform.positionX".to_string(), ParamValue::Number(0.0)),
+ ("transform.positionY".to_string(), ParamValue::Number(0.0)),
+ ("transform.scaleX".to_string(), ParamValue::Number(1.0)),
+ ("transform.scaleY".to_string(), ParamValue::Number(1.0)),
+ ("transform.rotate".to_string(), ParamValue::Number(0.0)),
+ ("opacity".to_string(), ParamValue::Number(1.0)),
+ ])
+}
+
+fn base_element(id: &str, start_time: MediaTime, duration: MediaTime) -> BaseTimelineElement {
+ BaseTimelineElement {
+ id: id.to_string(),
+ name: id.to_string(),
+ start_time,
+ duration,
+ trim_start: MediaTime::ZERO,
+ trim_end: MediaTime::ZERO,
+ source_duration: None,
+ animations: None,
+ params: visual_params(),
+ }
+}
+
+const IMAGE_DURATION: fn() -> MediaTime = || seconds(5.0);
+const VIDEO_DURATION: fn() -> MediaTime = || seconds(10.0);
+const TAIL_DURATION: fn() -> MediaTime = || seconds(5.0);
+const PUSH_DELTA: fn() -> MediaTime = || seconds(3.0);
+
+fn build_image_element() -> Element {
+ Element::Image(ImageElement {
+ base: base_element("image-1", MediaTime::ZERO, IMAGE_DURATION()),
+ media_id: "media-image-1".to_string(),
+ hidden: None,
+ effects: None,
+ masks: None,
+ })
+}
+
+fn build_video_element(id: &str, start_time: MediaTime, duration: MediaTime) -> Element {
+ Element::Video(VideoElement {
+ base: BaseTimelineElement {
+ source_duration: Some(duration),
+ ..base_element(id, start_time, duration)
+ },
+ media_id: format!("media-{id}"),
+ is_source_audio_enabled: None,
+ hidden: None,
+ retime: None,
+ effects: None,
+ masks: None,
+ })
+}
+
+fn seed_tracks() -> (SceneTracks, String) {
+ let main = Track::Video {
+ id: "main-track".to_string(),
+ name: "Main Track".to_string(),
+ elements: vec![
+ build_image_element(),
+ build_video_element("video-1", IMAGE_DURATION(), VIDEO_DURATION()),
+ build_video_element("video-2", IMAGE_DURATION() + VIDEO_DURATION(), TAIL_DURATION()),
+ ],
+ muted: false,
+ hidden: false,
+ };
+ (
+ SceneTracks {
+ overlay: Vec::new(),
+ main,
+ audio: Vec::new(),
+ },
+ "main-track".to_string(),
+ )
+}
+
+fn get_element<'a>(tracks: &'a SceneTracks, element_id: &str) -> Option<&'a Element> {
+ tracks
+ .main
+ .elements()
+ .iter()
+ .find(|element| element.base().id == element_id)
+}
+
+#[test]
+fn resize_and_push_land_in_one_applied_update() {
+ let (tracks, track_id) = seed_tracks();
+
+ let members = build_resize_members(
+ &tracks,
+ &[ElementRef {
+ track_id: track_id.clone(),
+ element_id: "image-1".to_string(),
+ }],
+ );
+ let result = compute_group_resize(&members, ResizeSide::Right, PUSH_DELTA(), FPS);
+
+ let mut updated = tracks.clone();
+ for update in &result.updates {
+ updated = update_element_in_scene_tracks(
+ &updated,
+ &update.track_id,
+ &update.element_id,
+ |element| {
+ let mut next = element.clone();
+ let base = next.base_mut();
+ base.trim_start = update.patch.trim_start;
+ base.trim_end = update.patch.trim_end;
+ base.start_time = update.patch.start_time;
+ base.duration = update.patch.duration;
+ next
+ },
+ None,
+ );
+ }
+
+ assert_eq!(
+ get_element(&updated, "image-1").unwrap().base().duration,
+ IMAGE_DURATION() + PUSH_DELTA()
+ );
+ assert_eq!(
+ get_element(&updated, "video-1").unwrap().base().start_time,
+ IMAGE_DURATION() + PUSH_DELTA()
+ );
+ assert_eq!(
+ get_element(&updated, "video-2").unwrap().base().start_time,
+ IMAGE_DURATION() + VIDEO_DURATION() + PUSH_DELTA()
+ );
+}
diff --git a/rust/crates/timeline/tests/placement_resolve.rs b/rust/crates/timeline/tests/placement_resolve.rs
new file mode 100644
index 00000000..f79b00dd
--- /dev/null
+++ b/rust/crates/timeline/tests/placement_resolve.rs
@@ -0,0 +1,624 @@
+use scene::{
+ AudioElement, BaseTimelineElement, Element, GraphicElement, ParamValue, ParamValues,
+ SceneTracks, TextElement, Track, UploadAudioElement, VideoElement,
+};
+use time::MediaTime;
+use timeline::placement::{
+ InsertDirection, PlacementResult, PlacementStrategy, PlacementSubject, PlacementTimeSpan,
+ ResolveTrackPlacementParams, VerticalDragDirection, resolve_track_placement,
+};
+use timeline::types::{ElementType, TrackType};
+
+fn ticks(value: i64) -> MediaTime {
+ MediaTime::from_ticks(value)
+}
+
+fn base_element(id: &str, start_time: i64, duration: i64) -> BaseTimelineElement {
+ BaseTimelineElement {
+ id: id.to_string(),
+ name: id.to_string(),
+ start_time: ticks(start_time),
+ duration: ticks(duration),
+ trim_start: MediaTime::ZERO,
+ trim_end: MediaTime::ZERO,
+ source_duration: None,
+ animations: None,
+ params: ParamValues::new(),
+ }
+}
+
+fn visual_params() -> ParamValues {
+ ParamValues::from([
+ ("transform.positionX".to_string(), ParamValue::Number(0.0)),
+ ("transform.positionY".to_string(), ParamValue::Number(0.0)),
+ ("transform.scaleX".to_string(), ParamValue::Number(1.0)),
+ ("transform.scaleY".to_string(), ParamValue::Number(1.0)),
+ ("transform.rotate".to_string(), ParamValue::Number(0.0)),
+ ("opacity".to_string(), ParamValue::Number(1.0)),
+ ])
+}
+
+fn build_element(element_type: ElementType, id: &str, start_time: i64, duration: i64) -> Element {
+ match element_type {
+ ElementType::Audio => Element::Audio(AudioElement::Upload(UploadAudioElement {
+ base: BaseTimelineElement {
+ params: ParamValues::from([
+ ("volume".to_string(), ParamValue::Number(1.0)),
+ ("muted".to_string(), ParamValue::Bool(false)),
+ ]),
+ ..base_element(id, start_time, duration)
+ },
+ retime: None,
+ media_id: format!("media-{id}"),
+ })),
+ ElementType::Graphic => Element::Graphic(GraphicElement {
+ base: BaseTimelineElement {
+ params: visual_params(),
+ ..base_element(id, start_time, duration)
+ },
+ definition_id: format!("graphic-{id}"),
+ hidden: None,
+ effects: None,
+ masks: None,
+ }),
+ ElementType::Text => Element::Text(TextElement {
+ base: BaseTimelineElement {
+ params: visual_params(),
+ ..base_element(id, start_time, duration)
+ },
+ hidden: None,
+ effects: None,
+ }),
+ ElementType::Video => Element::Video(VideoElement {
+ base: BaseTimelineElement {
+ params: visual_params(),
+ ..base_element(id, start_time, duration)
+ },
+ media_id: format!("media-{id}"),
+ is_source_audio_enabled: None,
+ hidden: None,
+ retime: None,
+ effects: None,
+ masks: None,
+ }),
+ _ => panic!("unsupported test element type"),
+ }
+}
+
+fn build_track(track_type: TrackType, id: &str, elements: Vec<Element>) -> Track {
+ match track_type {
+ TrackType::Audio => Track::Audio {
+ id: id.to_string(),
+ name: id.to_string(),
+ elements,
+ muted: false,
+ },
+ TrackType::Graphic => Track::Graphic {
+ id: id.to_string(),
+ name: id.to_string(),
+ elements,
+ hidden: false,
+ },
+ TrackType::Text => Track::Text {
+ id: id.to_string(),
+ name: id.to_string(),
+ elements,
+ hidden: false,
+ },
+ TrackType::Video => Track::Video {
+ id: id.to_string(),
+ name: id.to_string(),
+ elements,
+ muted: false,
+ hidden: false,
+ },
+ TrackType::Effect => Track::Effect {
+ id: id.to_string(),
+ name: id.to_string(),
+ elements,
+ hidden: false,
+ },
+ }
+}
+
+fn build_time_span(start_time: i64, duration: i64, exclude_element_id: Option<&str>) -> PlacementTimeSpan {
+ PlacementTimeSpan {
+ start_time: ticks(start_time),
+ duration: ticks(duration),
+ exclude_element_id: exclude_element_id.map(str::to_string),
+ }
+}
+
+fn build_scene_tracks(overlay: Vec<Track>, main: Option<Track>, audio: Vec<Track>) -> SceneTracks {
+ SceneTracks {
+ overlay,
+ main: main.unwrap_or_else(|| build_track(TrackType::Video, "video-main", Vec::new())),
+ audio,
+ }
+}
+
+fn resolve(
+ tracks: SceneTracks,
+ element_type: ElementType,
+ time_spans: Vec<PlacementTimeSpan>,
+ strategy: PlacementStrategy,
+) -> Option<PlacementResult> {
+ resolve_track_placement(&ResolveTrackPlacementParams {
+ tracks,
+ subject: PlacementSubject::ElementType(element_type),
+ time_spans,
+ strategy,
+ })
+}
+
+#[test]
+fn explicit_returns_the_requested_compatible_track() {
+ let tracks = build_scene_tracks(
+ vec![build_track(TrackType::Text, "text-1", Vec::new())],
+ None,
+ Vec::new(),
+ );
+
+ let result = resolve(
+ tracks,
+ ElementType::Text,
+ vec![build_time_span(2, 3, None)],
+ PlacementStrategy::Explicit {
+ track_id: "text-1".to_string(),
+ },
+ );
+
+ assert_eq!(
+ result,
+ Some(PlacementResult::ExistingTrack {
+ track_id: "text-1".to_string(),
+ track_index: 0,
+ track_type: TrackType::Text,
+ adjusted_start_time: None,
+ })
+ );
+}
+
+#[test]
+fn explicit_rejects_missing_and_incompatible_tracks() {
+ let tracks = || {
+ build_scene_tracks(
+ Vec::new(),
+ Some(build_track(TrackType::Video, "video-1", Vec::new())),
+ Vec::new(),
+ )
+ };
+
+ let missing = resolve(
+ tracks(),
+ ElementType::Text,
+ vec![build_time_span(0, 1, None)],
+ PlacementStrategy::Explicit {
+ track_id: "missing".to_string(),
+ },
+ );
+ assert_eq!(missing, None);
+
+ let incompatible = resolve(
+ tracks(),
+ ElementType::Text,
+ vec![build_time_span(0, 1, None)],
+ PlacementStrategy::Explicit {
+ track_id: "video-1".to_string(),
+ },
+ );
+ assert_eq!(incompatible, None);
+}
+
+#[test]
+fn first_available_picks_the_first_compatible_track_without_overlap() {
+ let tracks = build_scene_tracks(
+ vec![
+ build_track(
+ TrackType::Text,
+ "text-1",
+ vec![build_element(ElementType::Text, "a", 0, 5)],
+ ),
+ build_track(TrackType::Text, "text-2", Vec::new()),
+ ],
+ None,
+ Vec::new(),
+ );
+
+ let result = resolve(
+ tracks,
+ ElementType::Text,
+ vec![build_time_span(2, 1, None)],
+ PlacementStrategy::FirstAvailable,
+ );
+
+ assert_eq!(
+ result,
+ Some(PlacementResult::ExistingTrack {
+ track_id: "text-2".to_string(),
+ track_index: 1,
+ track_type: TrackType::Text,
+ adjusted_start_time: None,
+ })
+ );
+}
+
+#[test]
+fn first_available_creates_a_new_track_when_all_compatible_tracks_are_full() {
+ let tracks = build_scene_tracks(
+ vec![build_track(
+ TrackType::Graphic,
+ "graphic-1",
+ vec![build_element(ElementType::Graphic, "a", 0, 5)],
+ )],
+ Some(build_track(TrackType::Video, "video-main", Vec::new())),
+ vec![build_track(TrackType::Audio, "audio-1", Vec::new())],
+ );
+
+ let result = resolve(
+ tracks,
+ ElementType::Graphic,
+ vec![build_time_span(1, 1, None)],
+ PlacementStrategy::FirstAvailable,
+ );
+
+ assert_eq!(
+ result,
+ Some(PlacementResult::NewTrack {
+ track_type: TrackType::Graphic,
+ insert_index: 0,
+ insert_position: None,
+ })
+ );
+}
+
+#[test]
+fn prefer_index_uses_the_preferred_track_when_it_fits() {
+ let tracks = build_scene_tracks(
+ Vec::new(),
+ None,
+ vec![build_track(TrackType::Audio, "audio-1", Vec::new())],
+ );
+
+ let result = resolve(
+ tracks,
+ ElementType::Audio,
+ vec![build_time_span(3, 2, None)],
+ PlacementStrategy::PreferIndex {
+ track_index: 0,
+ hover_direction: InsertDirection::Below,
+ vertical_drag_direction: None,
+ create_new_track_only: false,
+ },
+ );
+
+ assert_eq!(
+ result,
+ Some(PlacementResult::NewTrack {
+ track_type: TrackType::Audio,
+ insert_index: 1,
+ insert_position: Some(InsertDirection::Below),
+ })
+ );
+}
+
+#[test]
+fn prefer_index_creates_a_new_overlay_track_above_the_main_track() {
+ let tracks = build_scene_tracks(
+ Vec::new(),
+ Some(build_track(TrackType::Video, "video-main", Vec::new())),
+ vec![build_track(TrackType::Audio, "audio-1", Vec::new())],
+ );
+
+ let result = resolve(
+ tracks,
+ ElementType::Graphic,
+ vec![build_time_span(1, 2, None)],
+ PlacementStrategy::PreferIndex {
+ track_index: 1,
+ hover_direction: InsertDirection::Below,
+ vertical_drag_direction: None,
+ create_new_track_only: false,
+ },
+ );
+
+ assert_eq!(
+ result,
+ Some(PlacementResult::NewTrack {
+ track_type: TrackType::Graphic,
+ insert_index: 0,
+ insert_position: Some(InsertDirection::Above),
+ })
+ );
+}
+
+#[test]
+fn prefer_index_keeps_audio_tracks_below_the_main_track() {
+ let tracks = build_scene_tracks(
+ vec![build_track(TrackType::Text, "text-1", Vec::new())],
+ Some(build_track(TrackType::Video, "video-main", Vec::new())),
+ vec![build_track(TrackType::Audio, "audio-1", Vec::new())],
+ );
+
+ let result = resolve(
+ tracks,
+ ElementType::Audio,
+ vec![build_time_span(0, 1, None)],
+ PlacementStrategy::PreferIndex {
+ track_index: 0,
+ hover_direction: InsertDirection::Above,
+ vertical_drag_direction: None,
+ create_new_track_only: true,
+ },
+ );
+
+ assert_eq!(
+ result,
+ Some(PlacementResult::NewTrack {
+ track_type: TrackType::Audio,
+ insert_index: 2,
+ insert_position: Some(InsertDirection::Below),
+ })
+ );
+}
+
+#[test]
+fn above_source_tries_the_track_above_source_then_any_compatible_track() {
+ let tracks = build_scene_tracks(
+ vec![
+ build_track(TrackType::Text, "text-top", Vec::new()),
+ build_track(
+ TrackType::Text,
+ "text-middle",
+ vec![build_element(ElementType::Text, "a", 0, 5)],
+ ),
+ build_track(TrackType::Text, "text-source", Vec::new()),
+ ],
+ None,
+ Vec::new(),
+ );
+
+ let result = resolve(
+ tracks,
+ ElementType::Text,
+ vec![build_time_span(1, 1, None)],
+ PlacementStrategy::AboveSource {
+ source_track_index: 2,
+ },
+ );
+
+ assert_eq!(
+ result,
+ Some(PlacementResult::ExistingTrack {
+ track_id: "text-top".to_string(),
+ track_index: 0,
+ track_type: TrackType::Text,
+ adjusted_start_time: None,
+ })
+ );
+}
+
+#[test]
+fn above_source_creates_a_new_overlay_track_in_the_overlay_zone_when_none_fit() {
+ let tracks = build_scene_tracks(
+ vec![
+ build_track(
+ TrackType::Text,
+ "text-top",
+ vec![build_element(ElementType::Text, "a", 0, 5)],
+ ),
+ build_track(
+ TrackType::Text,
+ "text-source",
+ vec![build_element(ElementType::Text, "b", 0, 5)],
+ ),
+ ],
+ None,
+ Vec::new(),
+ );
+
+ let result = resolve(
+ tracks,
+ ElementType::Text,
+ vec![build_time_span(1, 1, None)],
+ PlacementStrategy::AboveSource {
+ source_track_index: 1,
+ },
+ );
+
+ assert_eq!(
+ result,
+ Some(PlacementResult::NewTrack {
+ track_type: TrackType::Text,
+ insert_index: 0,
+ insert_position: None,
+ })
+ );
+}
+
+#[test]
+fn always_new_honors_highest_and_default_insertion_rules() {
+ use timeline::placement::NewTrackPosition;
+
+ let tracks = || {
+ build_scene_tracks(
+ Vec::new(),
+ Some(build_track(TrackType::Video, "video-main", Vec::new())),
+ vec![build_track(TrackType::Audio, "audio-1", Vec::new())],
+ )
+ };
+
+ let highest = resolve(
+ tracks(),
+ ElementType::Audio,
+ Vec::new(),
+ PlacementStrategy::AlwaysNew {
+ position: NewTrackPosition::Highest,
+ },
+ );
+ assert_eq!(
+ highest,
+ Some(PlacementResult::NewTrack {
+ track_type: TrackType::Audio,
+ insert_index: 1,
+ insert_position: None,
+ })
+ );
+
+ let default = resolve(
+ tracks(),
+ ElementType::Audio,
+ Vec::new(),
+ PlacementStrategy::AlwaysNew {
+ position: NewTrackPosition::Default,
+ },
+ );
+ assert_eq!(
+ default,
+ Some(PlacementResult::NewTrack {
+ track_type: TrackType::Audio,
+ insert_index: 2,
+ insert_position: None,
+ })
+ );
+}
+
+#[test]
+fn batch_time_spans_reject_tracks_when_any_span_overlaps() {
+ let tracks = build_scene_tracks(
+ Vec::new(),
+ None,
+ vec![build_track(
+ TrackType::Audio,
+ "audio-1",
+ vec![
+ build_element(ElementType::Audio, "a", 0, 2),
+ build_element(ElementType::Audio, "b", 5, 2),
+ ],
+ )],
+ );
+
+ let result = resolve(
+ tracks,
+ ElementType::Audio,
+ vec![build_time_span(3, 1, None), build_time_span(6, 1, None)],
+ PlacementStrategy::FirstAvailable,
+ );
+
+ assert_eq!(
+ result,
+ Some(PlacementResult::NewTrack {
+ track_type: TrackType::Audio,
+ insert_index: 1,
+ insert_position: None,
+ })
+ );
+}
+
+#[test]
+fn handles_main_only_timelines_and_track_type_derivation() {
+ use timeline::placement::NewTrackPosition;
+
+ let main_only = resolve(
+ build_scene_tracks(Vec::new(), None, Vec::new()),
+ ElementType::Video,
+ vec![build_time_span(0, 3, None)],
+ PlacementStrategy::PreferIndex {
+ track_index: 0,
+ hover_direction: InsertDirection::Below,
+ vertical_drag_direction: None,
+ create_new_track_only: true,
+ },
+ );
+ assert_eq!(
+ main_only,
+ Some(PlacementResult::NewTrack {
+ track_type: TrackType::Video,
+ insert_index: 0,
+ insert_position: Some(InsertDirection::Above),
+ })
+ );
+
+ let audio_default = resolve(
+ build_scene_tracks(
+ Vec::new(),
+ None,
+ vec![build_track(TrackType::Audio, "audio-1", Vec::new())],
+ ),
+ ElementType::Audio,
+ Vec::new(),
+ PlacementStrategy::AlwaysNew {
+ position: NewTrackPosition::Default,
+ },
+ );
+ assert_eq!(
+ audio_default,
+ Some(PlacementResult::NewTrack {
+ track_type: TrackType::Audio,
+ insert_index: 2,
+ insert_position: None,
+ })
+ );
+}
+
+#[test]
+fn existing_track_on_main_video_includes_adjusted_start_time_when_start_snaps() {
+ let tracks = build_scene_tracks(
+ Vec::new(),
+ Some(build_track(
+ TrackType::Video,
+ "video-main",
+ vec![build_element(ElementType::Video, "a", 5, 5)],
+ )),
+ Vec::new(),
+ );
+
+ let result = resolve(
+ tracks,
+ ElementType::Video,
+ vec![build_time_span(2, 2, None)],
+ PlacementStrategy::Explicit {
+ track_id: "video-main".to_string(),
+ },
+ );
+
+ assert_eq!(
+ result,
+ Some(PlacementResult::ExistingTrack {
+ track_id: "video-main".to_string(),
+ track_index: 0,
+ track_type: TrackType::Video,
+ adjusted_start_time: Some(MediaTime::ZERO),
+ })
+ );
+}
+
+#[test]
+fn prefer_index_uses_vertical_drag_direction_when_hovered_track_is_incompatible() {
+ let tracks = build_scene_tracks(
+ vec![build_track(TrackType::Text, "text-1", Vec::new())],
+ Some(build_track(TrackType::Video, "video-main", Vec::new())),
+ vec![build_track(TrackType::Audio, "audio-1", Vec::new())],
+ );
+
+ let result = resolve(
+ tracks,
+ ElementType::Audio,
+ vec![build_time_span(0, 1, None)],
+ PlacementStrategy::PreferIndex {
+ track_index: 0,
+ hover_direction: InsertDirection::Above,
+ vertical_drag_direction: Some(VerticalDragDirection::Down),
+ create_new_track_only: false,
+ },
+ );
+
+ assert_eq!(
+ result,
+ Some(PlacementResult::NewTrack {
+ track_type: TrackType::Audio,
+ insert_index: 2,
+ insert_position: Some(InsertDirection::Below),
+ })
+ );
+}
diff --git a/rust/crates/timeline/tests/retime.rs b/rust/crates/timeline/tests/retime.rs
new file mode 100644
index 00000000..b495fec6
--- /dev/null
+++ b/rust/crates/timeline/tests/retime.rs
@@ -0,0 +1,111 @@
+use scene::RetimeConfig;
+use timeline::retime::{
+ clamp_retime_rate, get_clip_time_at_source_time, get_effective_rate_at,
+ get_source_span_at_clip_time, get_source_time_at_clip_time,
+ get_timeline_duration_for_source_span, split_retime_at_clip_time,
+};
+
+const TWO_X: RetimeConfig = RetimeConfig {
+ rate: 2.0,
+ maintain_pitch: None,
+};
+const HALF_X: RetimeConfig = RetimeConfig {
+ rate: 0.5,
+ maintain_pitch: None,
+};
+
+#[test]
+fn maps_clip_time_to_source_time_at_2x_speed() {
+ assert_eq!(get_source_time_at_clip_time(5.0, Some(&TWO_X)), 10.0);
+}
+
+#[test]
+fn maps_clip_time_to_source_time_at_half_speed() {
+ assert_eq!(get_source_time_at_clip_time(4.0, Some(&HALF_X)), 2.0);
+}
+
+#[test]
+fn returns_clip_time_unchanged_when_no_retime() {
+ assert_eq!(get_source_time_at_clip_time(7.0, None), 7.0);
+}
+
+#[test]
+fn inverts_source_time_back_to_clip_time_at_2x_speed() {
+ assert_eq!(get_clip_time_at_source_time(10.0, Some(&TWO_X)), 5.0);
+}
+
+#[test]
+fn returns_effective_rate() {
+ assert_eq!(get_effective_rate_at(Some(&TWO_X)), 2.0);
+ assert_eq!(get_effective_rate_at(None), 1.0);
+}
+
+#[test]
+fn derives_timeline_duration_for_a_visible_source_span() {
+ assert_eq!(
+ get_timeline_duration_for_source_span(10.0, Some(&TWO_X)),
+ 5.0
+ );
+ assert_eq!(
+ get_timeline_duration_for_source_span(10.0, Some(&HALF_X)),
+ 20.0
+ );
+}
+
+#[test]
+fn clamps_invalid_rates_to_one() {
+ let zero = RetimeConfig {
+ rate: 0.0,
+ maintain_pitch: None,
+ };
+ let negative = RetimeConfig {
+ rate: -1.0,
+ maintain_pitch: None,
+ };
+ assert_eq!(get_source_time_at_clip_time(5.0, Some(&zero)), 5.0);
+ assert_eq!(get_source_time_at_clip_time(5.0, Some(&negative)), 5.0);
+ assert_eq!(clamp_retime_rate(f64::NAN), 1.0);
+ assert_eq!(clamp_retime_rate(f64::INFINITY), 1.0);
+}
+
+#[test]
+fn caps_retime_rates_above_5x() {
+ let hundred = RetimeConfig {
+ rate: 100.0,
+ maintain_pitch: None,
+ };
+ assert_eq!(get_source_time_at_clip_time(5.0, Some(&hundred)), 25.0);
+ assert_eq!(
+ get_timeline_duration_for_source_span(10.0, Some(&hundred)),
+ 2.0
+ );
+}
+
+#[test]
+fn measures_source_span_at_a_clip_time() {
+ assert_eq!(get_source_span_at_clip_time(5.0, Some(&TWO_X)), 10.0);
+}
+
+#[test]
+fn returns_zero_for_non_positive_clip_time() {
+ assert_eq!(get_source_span_at_clip_time(0.0, None), 0.0);
+ assert_eq!(get_source_span_at_clip_time(-1.0, None), 0.0);
+}
+
+#[test]
+fn passes_the_same_retime_to_both_halves_when_splitting() {
+ let retime = RetimeConfig {
+ rate: 1.5,
+ maintain_pitch: None,
+ };
+ let (left, right) = split_retime_at_clip_time(Some(&retime));
+ assert_eq!(left, Some(retime));
+ assert_eq!(right, Some(retime));
+}
+
+#[test]
+fn returns_none_on_both_sides_when_no_retime() {
+ let (left, right) = split_retime_at_clip_time(None);
+ assert_eq!(left, None);
+ assert_eq!(right, None);
+}
diff --git a/rust/crates/timeline/tests/split.rs b/rust/crates/timeline/tests/split.rs
new file mode 100644
index 00000000..ee9d4558
--- /dev/null
+++ b/rust/crates/timeline/tests/split.rs
@@ -0,0 +1,187 @@
+use scene::{
+ BaseTimelineElement, Element, ElementRef, ParamValue, ParamValues, RetimeConfig, SceneTracks,
+ Track, VideoElement,
+};
+use time::MediaTime;
+use timeline::split::{RetainSide, split_elements};
+
+fn seconds(value: f64) -> MediaTime {
+ MediaTime::from_seconds_f64(value).unwrap()
+}
+
+fn build_video_element(id: &str, duration: MediaTime, retime: Option<RetimeConfig>) -> Element {
+ Element::Video(VideoElement {
+ base: BaseTimelineElement {
+ id: id.to_string(),
+ name: id.to_string(),
+ start_time: MediaTime::ZERO,
+ duration,
+ trim_start: MediaTime::ZERO,
+ trim_end: MediaTime::ZERO,
+ source_duration: None,
+ animations: None,
+ params: ParamValues::from([
+ ("transform.positionX".to_string(), ParamValue::Number(0.0)),
+ ("transform.positionY".to_string(), ParamValue::Number(0.0)),
+ ("transform.scaleX".to_string(), ParamValue::Number(1.0)),
+ ("transform.scaleY".to_string(), ParamValue::Number(1.0)),
+ ("transform.rotate".to_string(), ParamValue::Number(0.0)),
+ ("opacity".to_string(), ParamValue::Number(1.0)),
+ ]),
+ },
+ media_id: format!("media-{id}"),
+ is_source_audio_enabled: None,
+ hidden: None,
+ retime,
+ effects: None,
+ masks: None,
+ })
+}
+
+fn seed_tracks(element: Element) -> (SceneTracks, ElementRef) {
+ let reference = ElementRef {
+ track_id: "main-track".to_string(),
+ element_id: element.base().id.clone(),
+ };
+ (
+ SceneTracks {
+ overlay: Vec::new(),
+ main: Track::Video {
+ id: "main-track".to_string(),
+ name: "Main Track".to_string(),
+ elements: vec![element],
+ muted: false,
+ hidden: false,
+ },
+ audio: Vec::new(),
+ },
+ reference,
+ )
+}
+
+#[test]
+fn split_produces_left_and_right_halves_with_shifted_trims() {
+ let (tracks, reference) = seed_tracks(build_video_element("video-1", seconds(10.0), None));
+
+ let result = split_elements(&tracks, &[reference], seconds(4.0), RetainSide::Both);
+
+ assert_eq!(result.right_side_elements.len(), 1);
+ let elements = result.tracks.main.elements();
+ assert_eq!(elements.len(), 2);
+
+ let left = &elements[0];
+ assert_eq!(left.base().id, "video-1");
+ assert_eq!(left.base().name, "video-1 (left)");
+ assert_eq!(left.base().start_time, MediaTime::ZERO);
+ assert_eq!(left.base().duration, seconds(4.0));
+ assert_eq!(left.base().trim_start, MediaTime::ZERO);
+ assert_eq!(left.base().trim_end, seconds(6.0));
+
+ let right = &elements[1];
+ assert_eq!(right.base().id, result.right_side_elements[0].element_id);
+ assert_eq!(right.base().name, "video-1 (right)");
+ assert_eq!(right.base().start_time, seconds(4.0));
+ assert_eq!(right.base().duration, seconds(6.0));
+ assert_eq!(right.base().trim_start, seconds(4.0));
+ assert_eq!(right.base().trim_end, MediaTime::ZERO);
+}
+
+#[test]
+fn split_scales_source_spans_by_the_retime_rate() {
+ let retime = RetimeConfig {
+ rate: 2.0,
+ maintain_pitch: None,
+ };
+ let (tracks, reference) =
+ seed_tracks(build_video_element("video-1", seconds(10.0), Some(retime)));
+
+ let result = split_elements(&tracks, &[reference], seconds(4.0), RetainSide::Both);
+
+ let elements = result.tracks.main.elements();
+ let left = &elements[0];
+ let right = &elements[1];
+ assert_eq!(left.base().trim_end, seconds(12.0));
+ assert_eq!(right.base().trim_start, seconds(8.0));
+ assert_eq!(
+ left.base().trim_start + left.base().duration * 2 + left.base().trim_end,
+ right.base().trim_start + right.base().duration * 2 + right.base().trim_end
+ );
+ assert_eq!(timeline::element_retime(left), Some(retime));
+ assert_eq!(timeline::element_retime(right), Some(retime));
+}
+
+#[test]
+fn retain_left_keeps_only_the_left_half() {
+ let (tracks, reference) = seed_tracks(build_video_element("video-1", seconds(10.0), None));
+
+ let result = split_elements(&tracks, &[reference], seconds(4.0), RetainSide::Left);
+
+ assert_eq!(result.right_side_elements.len(), 0);
+ let elements = result.tracks.main.elements();
+ assert_eq!(elements.len(), 1);
+ assert_eq!(elements[0].base().id, "video-1");
+ assert_eq!(elements[0].base().duration, seconds(4.0));
+ assert_eq!(elements[0].base().trim_end, seconds(6.0));
+}
+
+#[test]
+fn retain_right_keeps_only_the_right_half_with_a_new_id() {
+ let (tracks, reference) = seed_tracks(build_video_element("video-1", seconds(10.0), None));
+
+ let result = split_elements(&tracks, &[reference], seconds(4.0), RetainSide::Right);
+
+ assert_eq!(result.right_side_elements.len(), 1);
+ let elements = result.tracks.main.elements();
+ assert_eq!(elements.len(), 1);
+ let right = &elements[0];
+ assert_ne!(right.base().id, "video-1");
+ assert_eq!(right.base().id, result.right_side_elements[0].element_id);
+ assert_eq!(right.base().start_time, seconds(4.0));
+ assert_eq!(right.base().duration, seconds(6.0));
+ assert_eq!(right.base().trim_start, seconds(4.0));
+}
+
+#[test]
+fn split_outside_the_element_bounds_is_a_no_op() {
+ let (tracks, reference) = seed_tracks(build_video_element("video-1", seconds(10.0), None));
+
+ for split_time in [MediaTime::ZERO, seconds(10.0), seconds(12.0)] {
+ let result = split_elements(
+ &tracks,
+ std::slice::from_ref(&reference),
+ split_time,
+ RetainSide::Both,
+ );
+ assert_eq!(result.right_side_elements.len(), 0);
+ assert_eq!(result.tracks.main.elements().len(), 1);
+ assert_eq!(
+ result.tracks.main.elements()[0].base().duration,
+ seconds(10.0)
+ );
+ }
+}
+
+#[test]
+fn shifting_the_right_side_and_inserting_into_the_gap_keeps_layout_consistent() {
+ let (tracks, reference) = seed_tracks(build_video_element("video-1", seconds(10.0), None));
+
+ let result = split_elements(&tracks, &[reference], seconds(4.0), RetainSide::Both);
+ let right_ref = result.right_side_elements[0].clone();
+
+ let shifted = timeline::update_element_in_scene_tracks(
+ &result.tracks,
+ &right_ref.track_id,
+ &right_ref.element_id,
+ |element| {
+ let mut next = element.clone();
+ next.base_mut().start_time = element.base().start_time + seconds(3.0);
+ next
+ },
+ None,
+ );
+
+ let elements = shifted.main.elements();
+ assert_eq!(elements[0].base().duration, seconds(4.0));
+ assert_eq!(elements[1].base().start_time, seconds(7.0));
+ assert_eq!(elements[1].base().duration, seconds(6.0));
+}
diff --git a/rust/crates/timeline/tests/update_pipeline.rs b/rust/crates/timeline/tests/update_pipeline.rs
new file mode 100644
index 00000000..a7f6f339
--- /dev/null
+++ b/rust/crates/timeline/tests/update_pipeline.rs
@@ -0,0 +1,145 @@
+use scene::{
+ BaseTimelineElement, Element, ParamValue, ParamValues, RetimeConfig, SceneTracks, Track,
+ VideoElement,
+};
+use time::MediaTime;
+use timeline::update_pipeline::{ElementPatch, ElementUpdateContext, apply_element_update};
+
+fn build_video_element() -> Element {
+ Element::Video(VideoElement {
+ base: BaseTimelineElement {
+ id: "video-1".to_string(),
+ name: "Video 1".to_string(),
+ start_time: MediaTime::ZERO,
+ duration: MediaTime::from_ticks(10),
+ trim_start: MediaTime::ZERO,
+ trim_end: MediaTime::ZERO,
+ source_duration: None,
+ animations: None,
+ params: ParamValues::from([
+ ("transform.positionX".to_string(), ParamValue::Number(0.0)),
+ ("transform.positionY".to_string(), ParamValue::Number(0.0)),
+ ("transform.scaleX".to_string(), ParamValue::Number(1.0)),
+ ("transform.scaleY".to_string(), ParamValue::Number(1.0)),
+ ("transform.rotate".to_string(), ParamValue::Number(0.0)),
+ ("opacity".to_string(), ParamValue::Number(1.0)),
+ ]),
+ },
+ media_id: "media-1".to_string(),
+ is_source_audio_enabled: None,
+ hidden: None,
+ retime: None,
+ effects: None,
+ masks: None,
+ })
+}
+
+fn build_tracks(element: Element) -> SceneTracks {
+ SceneTracks {
+ overlay: Vec::new(),
+ main: Track::Video {
+ id: "main-track".to_string(),
+ name: "Main".to_string(),
+ elements: vec![element],
+ muted: false,
+ hidden: false,
+ },
+ audio: Vec::new(),
+ }
+}
+
+#[test]
+fn rounds_retimed_durations_back_to_integer_media_time() {
+ let element = build_video_element();
+ let tracks = build_tracks(element.clone());
+
+ let updated = apply_element_update(
+ &element,
+ &ElementPatch {
+ retime: Some(Some(RetimeConfig {
+ rate: 1.5,
+ maintain_pitch: None,
+ })),
+ ..ElementPatch::default()
+ },
+ &ElementUpdateContext {
+ tracks: &tracks,
+ track_id: tracks.main.id(),
+ },
+ );
+
+ assert_eq!(updated.base().duration, MediaTime::from_ticks(7));
+ assert_eq!(timeline::element_retime(&updated).unwrap().rate, 1.5);
+}
+
+#[test]
+fn start_time_on_main_track_snaps_to_zero_before_the_earliest_element() {
+ let earliest = {
+ let mut element = build_video_element();
+ element.base_mut().id = "video-2".to_string();
+ element.base_mut().start_time = MediaTime::from_ticks(100);
+ element
+ };
+ let element = build_video_element();
+ let tracks = build_tracks(earliest);
+ let tracks = SceneTracks {
+ main: Track::Video {
+ id: "main-track".to_string(),
+ name: "Main".to_string(),
+ elements: vec![tracks.main.elements()[0].clone(), element.clone()],
+ muted: false,
+ hidden: false,
+ },
+ ..tracks
+ };
+
+ let updated = apply_element_update(
+ &element,
+ &ElementPatch {
+ start_time: Some(MediaTime::from_ticks(50)),
+ ..ElementPatch::default()
+ },
+ &ElementUpdateContext {
+ tracks: &tracks,
+ track_id: "main-track",
+ },
+ );
+
+ assert_eq!(updated.base().start_time, MediaTime::ZERO);
+}
+
+#[test]
+fn start_time_on_overlay_track_only_clamps_negative_values() {
+ let element = build_video_element();
+ let tracks = SceneTracks {
+ overlay: vec![Track::Video {
+ id: "overlay-track".to_string(),
+ name: "Overlay".to_string(),
+ elements: vec![element.clone()],
+ muted: false,
+ hidden: false,
+ }],
+ main: Track::Video {
+ id: "main-track".to_string(),
+ name: "Main".to_string(),
+ elements: Vec::new(),
+ muted: false,
+ hidden: false,
+ },
+ audio: Vec::new(),
+ };
+
+ let updated = apply_element_update(
+ &element,
+ &ElementPatch {
+ start_time: Some(MediaTime::from_ticks(-20)),
+ ..ElementPatch::default()
+ },
+ &ElementUpdateContext {
+ tracks: &tracks,
+ track_id: "overlay-track",
+ },
+ );
+
+ assert_eq!(updated.base().start_time, MediaTime::ZERO);
+}