diff options
| author | cashmere <cashmere@autistici.org> | 2026-07-28 20:14:51 +0200 |
|---|---|---|
| committer | cashmere <cashmere@autistici.org> | 2026-07-28 20:14:51 +0200 |
| commit | 32438ce25f69de99d2523b98237f62cfa90890d7 (patch) | |
| tree | 3a712c347dec0889bdea3328b4c817c2d9392b27 | |
| parent | f5a82152394fde4d8f740f664ab7737b82861618 (diff) | |
| download | opencut-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)
98 files changed, 13319 insertions, 255 deletions
@@ -450,6 +450,17 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1505bd5d3d116872e7271a6d4e16d81d0c8570876c8de68093a09ac269d8aac0" [[package]] +name = "audio" +version = "0.1.0" +dependencies = [ + "animation", + "media", + "scene", + "thiserror 2.0.18", + "time", +] + +[[package]] name = "autocfg" version = "1.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" @@ -2607,6 +2618,8 @@ name = "graphics" version = "0.1.0" dependencies = [ "scene", + "thiserror 2.0.18", + "tiny-skia", ] [[package]] @@ -3443,8 +3456,10 @@ dependencies = [ name = "mask-defs" version = "0.1.0" dependencies = [ + "cosmic-text 0.15.0", "scene", "thiserror 2.0.18", + "tiny-skia", ] [[package]] @@ -4020,7 +4035,11 @@ dependencies = [ name = "opencut-desktop" version = "0.1.0" dependencies = [ + "chrono", "gpui", + "scene", + "storage", + "time", ] [[package]] @@ -4806,6 +4825,17 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "19b30a45b0cd0bcca8037f3d0dc3421eaf95327a17cad11964fb8179b4fc4832" [[package]] +name = "renderer" +version = "0.1.0" +dependencies = [ + "compositor", + "scene", + "serde", + "serde_json", + "time", +] + +[[package]] name = "resvg" version = "0.45.1" source = "registry+https://github.com/rust-lang/crates.io-index" @@ -5050,6 +5080,7 @@ dependencies = [ "serde", "serde_json", "time", + "uuid", ] [[package]] @@ -19,5 +19,7 @@ members = [ "rust/crates/effect-defs", "rust/crates/mask-defs", "rust/crates/playback", + "rust/crates/renderer", + "rust/crates/audio", "rust/wasm", "rust/crates/compositor", ] diff --git a/apps/desktop/Cargo.toml b/apps/desktop/Cargo.toml index e09443ab..f69b0805 100644 --- a/apps/desktop/Cargo.toml +++ b/apps/desktop/Cargo.toml @@ -9,3 +9,7 @@ path = "src/main.rs" [dependencies] gpui = "0.2.2" +scene = { version = "0.1.0", path = "../../rust/crates/scene" } +storage = { version = "0.1.0", path = "../../rust/crates/storage" } +time = { version = "0.1.0", path = "../../rust/crates/time" } +chrono = "0.4" diff --git a/apps/desktop/src/app.rs b/apps/desktop/src/app.rs new file mode 100644 index 00000000..8eab4f87 --- /dev/null +++ b/apps/desktop/src/app.rs @@ -0,0 +1,62 @@ +use gpui::{Context, Window, div, prelude::*, rgb}; + +use crate::projects_view::ProjectsView; +use crate::theme; + +pub enum Screen { + Projects, + Editor { project_id: String, project_name: String }, +} + +pub struct OpenCutApp { + pub screen: Screen, + pub projects_view: gpui::Entity<ProjectsView>, +} + +impl OpenCutApp { + pub fn new(cx: &mut Context<Self>) -> Self { + let app = cx.weak_entity(); + let projects_view = cx.new(|_| ProjectsView::new(app)); + Self { + screen: Screen::Projects, + projects_view, + } + } + + pub fn open_project(&mut self, project_id: String, project_name: String, cx: &mut Context<Self>) { + self.screen = Screen::Editor { + project_id, + project_name, + }; + cx.notify(); + } +} + +impl Render for OpenCutApp { + fn render(&mut self, _window: &mut Window, _cx: &mut Context<Self>) -> impl IntoElement { + let content: gpui::AnyElement = match &self.screen { + Screen::Projects => self.projects_view.clone().into_any_element(), + Screen::Editor { project_name, project_id } => div() + .size_full() + .flex() + .flex_col() + .items_center() + .justify_center() + .gap_2() + .child(format!("Editing: {project_name}")) + .child( + div() + .text_sm() + .text_color(rgb(theme::TEXT_SECONDARY)) + .child(format!("Editor UI lands here — preview, timeline, panels ({project_id})")), + ) + .into_any_element(), + }; + + div() + .size_full() + .bg(rgb(theme::BACKGROUND)) + .text_color(rgb(theme::TEXT_PRIMARY)) + .child(content) + } +} diff --git a/apps/desktop/src/main.rs b/apps/desktop/src/main.rs index 0ba70959..0508aa33 100644 --- a/apps/desktop/src/main.rs +++ b/apps/desktop/src/main.rs @@ -1,24 +1,17 @@ -use gpui::{ - div, prelude::*, px, rgb, size, App, Application, Bounds, Context, SharedString, Window, - WindowBounds, WindowOptions, -}; +use std::path::PathBuf; -struct AppWindow { - title: SharedString, -} +use gpui::{App, AppContext, Application, Bounds, WindowBounds, WindowOptions, px, size}; + +mod app; +mod projects_view; +mod theme; -impl Render for AppWindow { - fn render(&mut self, _window: &mut Window, _cx: &mut Context<Self>) -> impl IntoElement { - div() - .size_full() - .bg(rgb(0x0f0f0f)) - .flex() - .justify_center() - .items_center() - .text_xl() - .text_color(rgb(0xffffff)) - .child(self.title.clone()) - } +use app::OpenCutApp; + +pub fn projects_root() -> PathBuf { + std::env::home_dir() + .unwrap_or_else(|| PathBuf::from(".")) + .join(".local/share/opencut/projects") } fn main() { @@ -29,11 +22,7 @@ fn main() { window_bounds: Some(WindowBounds::Windowed(bounds)), ..Default::default() }, - |_, cx| { - cx.new(|_| AppWindow { - title: "OpenCut".into(), - }) - }, + |_, cx| cx.new(|cx| OpenCutApp::new(cx)), ) .unwrap(); cx.activate(true); diff --git a/apps/desktop/src/projects_view.rs b/apps/desktop/src/projects_view.rs new file mode 100644 index 00000000..2d69cf22 --- /dev/null +++ b/apps/desktop/src/projects_view.rs @@ -0,0 +1,164 @@ +use gpui::{Context, SharedString, WeakEntity, Window, div, prelude::*, rgb}; +use scene::ProjectMetadata; +use storage::ProjectStore; + +use crate::app::OpenCutApp; +use crate::{projects_root, theme}; + +pub struct ProjectsView { + store: ProjectStore, + projects: Vec<ProjectMetadata>, + load_error: Option<String>, + app: WeakEntity<OpenCutApp>, +} + +impl ProjectsView { + pub fn new(app: WeakEntity<OpenCutApp>) -> Self { + let store = ProjectStore::new(projects_root()); + let (projects, load_error) = match store.list_projects() { + Ok(projects) => (projects, None), + Err(error) => (Vec::new(), Some(error.to_string())), + }; + Self { + store, + projects, + load_error, + app, + } + } + + fn reload(&mut self) { + match self.store.list_projects() { + Ok(projects) => { + self.projects = projects; + self.load_error = None; + } + Err(error) => self.load_error = Some(error.to_string()), + } + } + + fn create_project(&mut self, cx: &mut Context<Self>) { + let project = scene::new_project("Untitled Project"); + if self.store.create_project(&project).is_ok() { + self.open_project(project.metadata.id, project.metadata.name, cx); + } + } + + fn open_project(&mut self, id: String, name: String, cx: &mut Context<Self>) { + let _ = self.app.update(cx, |app, cx| app.open_project(id, name, cx)); + } + + fn delete_project(&mut self, id: &str, cx: &mut Context<Self>) { + let _ = self.store.delete_project(id); + self.reload(); + cx.notify(); + } +} + +impl Render for ProjectsView { + fn render(&mut self, _window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement { + let mut list = div().flex().flex_col().gap_2().w_full(); + + if let Some(error) = &self.load_error { + list = list.child( + div() + .text_color(rgb(0xef4444)) + .child(format!("Failed to load projects: {error}")), + ); + } + + if self.projects.is_empty() && self.load_error.is_none() { + list = list.child( + div() + .text_color(rgb(theme::TEXT_SECONDARY)) + .child("No projects yet. Create one to get started."), + ); + } + + for project in &self.projects { + let id = project.id.clone(); + let name = project.name.clone(); + let delete_id = project.id.clone(); + let updated = project.updated_at.format("%Y-%m-%d %H:%M").to_string(); + let duration = project.duration.to_seconds_f64(); + + list = list.child( + div() + .id(SharedString::from(format!("project-{id}"))) + .w_full() + .px_4() + .py_3() + .bg(rgb(theme::SURFACE)) + .rounded_md() + .border_1() + .border_color(rgb(theme::BORDER)) + .flex() + .flex_row() + .justify_between() + .items_center() + .cursor_pointer() + .hover(|style| style.bg(rgb(theme::SURFACE_HOVER))) + .on_click(cx.listener(move |this, _, _, cx| { + this.open_project(id.clone(), name.clone(), cx); + })) + .child( + div() + .flex() + .flex_col() + .child(div().child(project.name.clone())) + .child( + div() + .text_sm() + .text_color(rgb(theme::TEXT_SECONDARY)) + .child(format!("{duration:.1}s · updated {updated}")), + ), + ) + .child( + div() + .id(SharedString::from(format!("delete-{delete_id}"))) + .px_2() + .py_1() + .text_sm() + .text_color(rgb(theme::TEXT_SECONDARY)) + .rounded_sm() + .cursor_pointer() + .hover(|style| style.text_color(rgb(0xef4444))) + .on_click(cx.listener(move |this, _, _, cx| { + this.delete_project(&delete_id, cx); + cx.stop_propagation(); + })) + .child("Delete"), + ), + ); + } + + div() + .size_full() + .flex() + .flex_col() + .p_8() + .gap_6() + .child( + div() + .flex() + .flex_row() + .justify_between() + .items_center() + .w_full() + .child(div().text_xl().child("Projects")) + .child( + div() + .id("new-project") + .px_4() + .py_2() + .bg(rgb(theme::ACCENT)) + .rounded_md() + .cursor_pointer() + .hover(|style| style.bg(rgb(0x2563eb))) + .on_click(cx.listener(|this, _, _, cx| this.create_project(cx))) + .child("New project"), + ), + ) + .child(div().flex_1().overflow_y_hidden().child(list)) + } +} diff --git a/apps/desktop/src/theme.rs b/apps/desktop/src/theme.rs new file mode 100644 index 00000000..9bbe6579 --- /dev/null +++ b/apps/desktop/src/theme.rs @@ -0,0 +1,7 @@ +pub const BACKGROUND: u32 = 0x0f0f0f; +pub const SURFACE: u32 = 0x1a1a1a; +pub const SURFACE_HOVER: u32 = 0x262626; +pub const BORDER: u32 = 0x2e2e2e; +pub const TEXT_PRIMARY: u32 = 0xf5f5f5; +pub const TEXT_SECONDARY: u32 = 0x9c9c9c; +pub const ACCENT: u32 = 0x3b82f6; 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", + ¶ms, + 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( + ¶ms, + &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) => ¶m.key, + ParamDefinition::Boolean(param) => ¶m.key, + ParamDefinition::Color(param) => ¶m.key, + ParamDefinition::Select(param) => ¶m.key, + ParamDefinition::Text(param) => ¶m.key, + ParamDefinition::Font(param) => ¶m.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(¶m, &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(¶m, &ParamValue::Bool(true)), + Some(ParamValue::Bool(true)) + ); + assert_eq!( + coerce_param_value(¶m, &ParamValue::Bool(false)), + Some(ParamValue::Bool(false)) + ); + assert_eq!(coerce_param_value(¶m, &ParamValue::Number(1.0)), None); + assert_eq!( + coerce_param_value(¶m, &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(¶m, &ParamValue::String("#ff0000".to_string())), + Some(ParamValue::String("#ff0000".to_string())) + ); + assert_eq!( + coerce_param_value(¶m, &ParamValue::Number(0xff0000 as f64)), + None + ); + assert_eq!(coerce_param_value(¶m, &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 Binary files differnew file mode 100644 index 00000000..13c113e5 --- /dev/null +++ b/rust/crates/audio/tests/fixtures/sample.mp4 diff --git a/rust/crates/audio/tests/fixtures/tone.wav b/rust/crates/audio/tests/fixtures/tone.wav Binary files differnew file mode 100644 index 00000000..8d5f7886 --- /dev/null +++ b/rust/crates/audio/tests/fixtures/tone.wav 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(¶ms_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, ¶ms_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, ¶ms_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, ¶ms_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, ¶ms_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, ¶ms_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, ¶ms_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, ¶ms_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(¶m_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, ¶ms); + 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, ¶ms); + 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, ¶ms); + 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, ¶ms); + 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 { + ®ISTRY +} + +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(¶ms(&[("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(¶ms(&[("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(¶ms(&[("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(¶ms(&[ + ("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(¶ms(&[ + ("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(¶ms(&[ + ("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(¶ms(&[("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( + ¶ms.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, ¶ms.stroke_color), + Mask::CinematicBars { params, .. } + | Mask::Rectangle { params, .. } + | Mask::Ellipse { params, .. } + | Mask::Heart { params, .. } + | Mask::Diamond { params, .. } + | Mask::Star { params, .. } => (params.stroke_width, ¶ms.stroke_color), + Mask::Text { params, .. } => (params.stroke_width, ¶ms.stroke_color), + Mask::Freeform { params, .. } => (params.stroke_width, ¶ms.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( + ¶ms.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(¶ms.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(¶ms.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(¶ms.tracks.main) { + visible_tracks.push(¶ms.tracks.main); + } + visible_tracks.reverse(); + + let mut children = Vec::new(); + + if let Background::Blur { blur_intensity } = params.background { + let main_track = (!track_hidden(¶ms.tracks.main)).then_some(¶ms.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(¶ms) { + 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(¶ms.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, + ¶ms.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 Binary files differnew file mode 100644 index 00000000..3710f897 --- /dev/null +++ b/rust/crates/text/tests/fixtures/DejaVuSans-Bold.ttf diff --git a/rust/crates/text/tests/fixtures/DejaVuSans.ttf b/rust/crates/text/tests/fixtures/DejaVuSans.ttf Binary files differnew file mode 100644 index 00000000..1a126069 --- /dev/null +++ b/rust/crates/text/tests/fixtures/DejaVuSans.ttf 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(¶ms); + 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(¶ms); + 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(¶ms); + 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(¶ms); + 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(¶ms); + 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(¶ms).enabled); +} + +#[test] +fn resolve_scales_font_size_with_canvas_height() { + let params = dejavu_params("Hello"); + let resolved = resolve_text_layout(¶ms, 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(¶ms, 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(¶ms, 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(¶ms, 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(¶ms, 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(¶ms, 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(¶ms, 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(¶ms, 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(¶ms, 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(¶ms("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(¶ms("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(¶ms("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(¶ms("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); +} |
