diff options
| author | cashmere <cashmere@autistici.org> | 2026-07-28 19:41:16 +0200 |
|---|---|---|
| committer | cashmere <cashmere@autistici.org> | 2026-07-28 19:41:16 +0200 |
| commit | f5a82152394fde4d8f740f664ab7737b82861618 (patch) | |
| tree | 5a51b181abead1ce03d2dbfc12b77918d3be0e1b /rust | |
| parent | e837e7504379e100ff3b56282577ec67bb88bec9 (diff) | |
| download | opencut-f5a82152394fde4d8f740f664ab7737b82861618.tar.gz | |
feat: GPUI desktop port foundation
- scene crate: full project/scene/element model, serde round-trip
proven against the web SerializedProject shape
- transfer crate: .ocp zip container, cross-compat with web fflate
archives proven in both directions
- storage crate: filesystem project store replacing IndexedDB,
.ocp import/export
- media crate: ffmpeg-next 8 decoder (frames, audio, thumbnails,
waveform summaries)
- playback crate: wall-clock anchored, frame-rounded playback clock
- gpu crate: native texture readback; compositor renders headless
offscreen on Vulkan (spike test)
- flake devshell: rust toolchain, GPUI native deps, ffmpeg dev,
bindgen clang args; GPUI hello-world builds
- port plan in notes/gpui-port.md
Diffstat (limited to 'rust')
109 files changed, 10000 insertions, 0 deletions
diff --git a/rust/crates/animation/Cargo.toml b/rust/crates/animation/Cargo.toml new file mode 100644 index 00000000..bd21351e --- /dev/null +++ b/rust/crates/animation/Cargo.toml @@ -0,0 +1,10 @@ +[package] +name = "animation" +version = "0.1.0" +edition = "2024" + +[dependencies] +scene = { version = "0.1.0", path = "../scene" } +serde = { version = "1.0", features = ["derive"] } +serde_json = "1.0" +time = { version = "0.1.0", path = "../time" } diff --git a/rust/crates/animation/src/bezier.rs b/rust/crates/animation/src/bezier.rs new file mode 100644 index 00000000..6bec252f --- /dev/null +++ b/rust/crates/animation/src/bezier.rs @@ -0,0 +1,156 @@ +use scene::{CurveHandle, ScalarAnimationKey}; +use time::MediaTime; + +pub const BEZIER_SOLVE_ITERATIONS: usize = 20; + +#[derive(Clone, Copy, Debug, PartialEq)] +pub struct BezierHandle { + pub dt: f64, + pub dv: f64, +} + +impl From<CurveHandle> for BezierHandle { + fn from(handle: CurveHandle) -> Self { + Self { + dt: handle.dt.as_ticks() as f64, + dv: handle.dv, + } + } +} + +pub fn bezier_point(progress: f64, p0: f64, p1: f64, p2: f64, p3: f64) -> f64 { + let mt = 1.0 - progress; + mt * mt * mt * p0 + + 3.0 * mt * mt * progress * p1 + + 3.0 * mt * progress * progress * p2 + + progress * progress * progress * p3 +} + +pub fn default_right_handle( + left_key: &ScalarAnimationKey, + right_key: &ScalarAnimationKey, +) -> BezierHandle { + let span = (right_key.time - left_key.time).as_ticks() as f64; + let value_delta = right_key.value - left_key.value; + BezierHandle { + dt: span / 3.0, + dv: value_delta / 3.0, + } +} + +pub fn default_left_handle( + left_key: &ScalarAnimationKey, + right_key: &ScalarAnimationKey, +) -> BezierHandle { + let span = (right_key.time - left_key.time).as_ticks() as f64; + let value_delta = right_key.value - left_key.value; + BezierHandle { + dt: -span / 3.0, + dv: -value_delta / 3.0, + } +} + +pub fn segment_handles( + left_key: &ScalarAnimationKey, + right_key: &ScalarAnimationKey, +) -> (BezierHandle, BezierHandle) { + let right_handle = left_key + .right_handle + .map(BezierHandle::from) + .unwrap_or_else(|| default_right_handle(left_key, right_key)); + let left_handle = right_key + .left_handle + .map(BezierHandle::from) + .unwrap_or_else(|| default_left_handle(left_key, right_key)); + (right_handle, left_handle) +} + +pub fn solve_bezier_progress_for_time( + time: MediaTime, + left_key: &ScalarAnimationKey, + right_key: &ScalarAnimationKey, +) -> f64 { + let (right_handle, left_handle) = segment_handles(left_key, right_key); + let target = time.as_ticks() as f64; + let p0 = left_key.time.as_ticks() as f64; + let p3 = right_key.time.as_ticks() as f64; + let mut lower = 0.0; + let mut upper = 1.0; + + for _ in 0..BEZIER_SOLVE_ITERATIONS { + let mid = (lower + upper) / 2.0; + let estimate = bezier_point(mid, p0, p0 + right_handle.dt, p3 + left_handle.dt, p3); + if estimate < target { + lower = mid; + } else { + upper = mid; + } + } + + (lower + upper) / 2.0 +} + +#[cfg(test)] +mod tests { + use super::*; + use scene::{ScalarSegmentType, TangentMode}; + + fn key(time: i64, value: f64) -> ScalarAnimationKey { + ScalarAnimationKey { + id: format!("k{time}"), + time: MediaTime::from_ticks(time), + value, + left_handle: None, + right_handle: None, + segment_to_next: ScalarSegmentType::Bezier, + tangent_mode: TangentMode::Flat, + } + } + + #[test] + fn bezier_point_evaluates_cubic() { + assert_eq!(bezier_point(0.0, 0.0, 1.0, 2.0, 3.0), 0.0); + assert_eq!(bezier_point(1.0, 0.0, 1.0, 2.0, 3.0), 3.0); + assert_eq!(bezier_point(0.5, 0.0, 0.0, 1.0, 1.0), 0.5); + } + + #[test] + fn default_handles_split_span_into_thirds() { + let left = key(0, 10.0); + let right = key(90, 40.0); + let right_handle = default_right_handle(&left, &right); + let left_handle = default_left_handle(&left, &right); + assert_eq!(right_handle.dt, 30.0); + assert_eq!(right_handle.dv, 10.0); + assert_eq!(left_handle.dt, -30.0); + assert_eq!(left_handle.dv, -10.0); + } + + #[test] + fn solve_progress_is_linear_for_diagonal_control_points() { + let left = key(0, 0.0); + let right = key(100, 100.0); + let progress = solve_bezier_progress_for_time(MediaTime::from_ticks(50), &left, &right); + assert!((progress - 0.5).abs() < 1e-6); + let progress = solve_bezier_progress_for_time(MediaTime::from_ticks(25), &left, &right); + assert!((progress - 0.25).abs() < 1e-6); + } + + #[test] + fn solve_progress_respects_flat_tangents() { + let mut left = key(0, 0.0); + left.right_handle = Some(CurveHandle { + dt: MediaTime::from_ticks(0), + dv: 0.0, + }); + let mut right = key(100, 100.0); + right.left_handle = Some(CurveHandle { + dt: MediaTime::from_ticks(0), + dv: 0.0, + }); + let early = solve_bezier_progress_for_time(MediaTime::from_ticks(25), &left, &right); + let late = solve_bezier_progress_for_time(MediaTime::from_ticks(75), &left, &right); + assert!(early < 0.25); + assert!(late > 0.75); + } +} diff --git a/rust/crates/animation/src/channel_data.rs b/rust/crates/animation/src/channel_data.rs new file mode 100644 index 00000000..a05396e5 --- /dev/null +++ b/rust/crates/animation/src/channel_data.rs @@ -0,0 +1,92 @@ +use scene::{AnimationChannel, ChannelData, CompositeChannelData}; + +const LEGACY_ANIMATION_STORAGE_KEYS: [&str; 2] = ["bindings", "channels"]; + +pub fn is_animation_storage_key(key: &str) -> bool { + !LEGACY_ANIMATION_STORAGE_KEYS.contains(&key) +} + +fn component_order(key: &str) -> usize { + match key { + "value" => 0, + "r" => 1, + "g" => 2, + "b" => 3, + "a" => 4, + _ => 5, + } +} + +pub fn ordered_composite_entries(data: &CompositeChannelData) -> Vec<(&String, &AnimationChannel)> { + let mut entries: Vec<(&String, &AnimationChannel)> = data.iter().collect(); + entries.sort_by_key(|(key, _)| component_order(key)); + entries +} + +pub fn get_channels_from_data(data: Option<&ChannelData>) -> Vec<&AnimationChannel> { + match data { + Some(ChannelData::Single(channel)) => vec![channel], + Some(ChannelData::Composite(components)) => ordered_composite_entries(components) + .into_iter() + .map(|(_, channel)| channel) + .collect(), + None => Vec::new(), + } +} + +pub fn get_channel_entries_from_data(data: Option<&ChannelData>) -> Vec<(String, &AnimationChannel)> { + match data { + Some(ChannelData::Single(channel)) => vec![("value".to_string(), channel)], + Some(ChannelData::Composite(components)) => ordered_composite_entries(components) + .into_iter() + .map(|(key, channel)| (key.clone(), channel)) + .collect(), + None => Vec::new(), + } +} + +#[cfg(test)] +mod tests { + use super::*; + use scene::{AnimationChannel, DiscreteChannel, ScalarChannel}; + use std::collections::BTreeMap; + + fn scalar_channel() -> AnimationChannel { + AnimationChannel::Scalar(ScalarChannel { + keys: Vec::new(), + extrapolation: None, + }) + } + + #[test] + fn leaf_data_yields_value_entry() { + let data = ChannelData::Single(scalar_channel()); + let entries = get_channel_entries_from_data(Some(&data)); + assert_eq!(entries.len(), 1); + assert_eq!(entries[0].0, "value"); + } + + #[test] + fn composite_entries_follow_canonical_component_order() { + let mut components = BTreeMap::new(); + components.insert("a".to_string(), scalar_channel()); + components.insert("b".to_string(), scalar_channel()); + components.insert("g".to_string(), scalar_channel()); + components.insert("r".to_string(), scalar_channel()); + components.insert( + "mode".to_string(), + AnimationChannel::Discrete(DiscreteChannel { keys: Vec::new() }), + ); + let data = ChannelData::Composite(components); + let entries = get_channel_entries_from_data(Some(&data)); + let keys: Vec<&str> = entries.iter().map(|(key, _)| key.as_str()).collect(); + assert_eq!(keys, ["r", "g", "b", "a", "mode"]); + } + + #[test] + fn legacy_storage_keys_are_rejected() { + assert!(!is_animation_storage_key("bindings")); + assert!(!is_animation_storage_key("channels")); + assert!(is_animation_storage_key("opacity")); + } +} diff --git a/rust/crates/animation/src/channel_layout.rs b/rust/crates/animation/src/channel_layout.rs new file mode 100644 index 00000000..59bbc3f6 --- /dev/null +++ b/rust/crates/animation/src/channel_layout.rs @@ -0,0 +1,124 @@ +use scene::ParamValue; +use serde::{Deserialize, Serialize}; +use std::collections::BTreeMap; + +use crate::color::{LinearRgba, parse_color_to_linear_rgba}; +use crate::types::AnimationInterpolation; + +#[derive(Serialize, Deserialize, Clone, Copy, Debug, Eq, PartialEq)] +#[serde(rename_all = "camelCase")] +pub enum ChannelValueKind { + Scalar, + Discrete, +} + +#[derive(Serialize, Deserialize, Clone, Debug, PartialEq)] +#[serde(rename_all = "camelCase")] +pub struct ChannelComponentDefinition { + pub key: String, + pub value_kind: ChannelValueKind, + pub default_interpolation: AnimationInterpolation, +} + +#[derive(Clone, Debug, PartialEq)] +pub enum ChannelLayout { + Leaf(ChannelComponentDefinition), + Composite(Vec<ChannelComponentDefinition>), +} + +impl ChannelLayout { + pub fn components(&self) -> &[ChannelComponentDefinition] { + match self { + ChannelLayout::Leaf(component) => std::slice::from_ref(component), + ChannelLayout::Composite(components) => components, + } + } + + pub fn decompose(&self, value: &ParamValue) -> Option<BTreeMap<String, ParamValue>> { + match self { + ChannelLayout::Leaf(component) => { + let mut values = BTreeMap::new(); + values.insert(component.key.clone(), value.clone()); + Some(values) + } + ChannelLayout::Composite(_) => { + let ParamValue::String(color) = value else { + return None; + }; + let LinearRgba { r, g, b, a } = parse_color_to_linear_rgba(color)?; + let mut values = BTreeMap::new(); + values.insert("r".to_string(), ParamValue::Number(r)); + values.insert("g".to_string(), ParamValue::Number(g)); + values.insert("b".to_string(), ParamValue::Number(b)); + values.insert("a".to_string(), ParamValue::Number(a)); + Some(values) + } + } + } +} + +pub fn number_channel_layout() -> ChannelLayout { + ChannelLayout::Leaf(ChannelComponentDefinition { + key: "value".to_string(), + value_kind: ChannelValueKind::Scalar, + default_interpolation: AnimationInterpolation::Linear, + }) +} + +pub fn boolean_channel_layout() -> ChannelLayout { + ChannelLayout::Leaf(ChannelComponentDefinition { + key: "value".to_string(), + value_kind: ChannelValueKind::Discrete, + default_interpolation: AnimationInterpolation::Hold, + }) +} + +pub fn string_channel_layout() -> ChannelLayout { + ChannelLayout::Leaf(ChannelComponentDefinition { + key: "value".to_string(), + value_kind: ChannelValueKind::Discrete, + default_interpolation: AnimationInterpolation::Hold, + }) +} + +pub fn color_channel_layout() -> ChannelLayout { + let component = |key: &str| ChannelComponentDefinition { + key: key.to_string(), + value_kind: ChannelValueKind::Scalar, + default_interpolation: AnimationInterpolation::Linear, + }; + ChannelLayout::Composite(vec![ + component("r"), + component("g"), + component("b"), + component("a"), + ]) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn leaf_decompose_wraps_value_under_component_key() { + let layout = number_channel_layout(); + let values = layout.decompose(&ParamValue::Number(3.0)).unwrap(); + assert_eq!(values.get("value"), Some(&ParamValue::Number(3.0))); + } + + #[test] + fn color_decompose_parses_hex_into_linear_components() { + let layout = color_channel_layout(); + let values = layout + .decompose(&ParamValue::String("#ff0000".to_string())) + .unwrap(); + assert_eq!(values.get("r"), Some(&ParamValue::Number(1.0))); + assert_eq!(values.get("g"), Some(&ParamValue::Number(0.0))); + assert_eq!(values.get("a"), Some(&ParamValue::Number(1.0))); + assert!( + layout + .decompose(&ParamValue::Number(1.0)) + .is_none() + ); + } +} diff --git a/rust/crates/animation/src/color.rs b/rust/crates/animation/src/color.rs new file mode 100644 index 00000000..03f409a1 --- /dev/null +++ b/rust/crates/animation/src/color.rs @@ -0,0 +1,137 @@ +#[derive(Clone, Copy, Debug, PartialEq)] +pub struct LinearRgba { + pub r: f64, + pub g: f64, + pub b: f64, + pub a: f64, +} + +fn clamp_unit(value: f64) -> f64 { + value.max(0.0).min(1.0) +} + +fn srgb_to_linear(value: f64) -> f64 { + if value <= 0.04045 { + value / 12.92 + } else { + ((value + 0.055) / 1.055).powf(2.4) + } +} + +fn linear_to_srgb(value: f64) -> f64 { + let clamped = clamp_unit(value); + if clamped <= 0.003_130_8 { + clamped * 12.92 + } else { + 1.055 * clamped.powf(1.0 / 2.4) - 0.055 + } +} + +fn parse_hex_digit(digit: char) -> Option<f64> { + digit.to_digit(16).map(|value| value as f64 / 15.0) +} + +fn parse_hex_pair(pair: &str) -> Option<f64> { + u8::from_str_radix(pair, 16) + .ok() + .map(|value| value as f64 / 255.0) +} + +pub fn parse_color_to_linear_rgba(color: &str) -> Option<LinearRgba> { + let hex = color.strip_prefix('#')?; + let (r, g, b, a) = match hex.len() { + 3 | 4 => { + let mut digits = hex.chars(); + 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); + (r, g, b, a) + } + 6 | 8 => { + let r = parse_hex_pair(&hex[0..2])?; + let g = parse_hex_pair(&hex[2..4])?; + let b = parse_hex_pair(&hex[4..6])?; + let a = if hex.len() == 8 { + parse_hex_pair(&hex[6..8])? + } else { + 1.0 + }; + (r, g, b, a) + } + _ => return None, + }; + Some(LinearRgba { + r: srgb_to_linear(r), + g: srgb_to_linear(g), + b: srgb_to_linear(b), + a: clamp_unit(a), + }) +} + +fn hex_component(value: f64) -> u8 { + (clamp_unit(value) * 255.0).round() as u8 +} + +pub fn format_linear_rgba(color: &LinearRgba) -> String { + let r = hex_component(linear_to_srgb(color.r)); + let g = hex_component(linear_to_srgb(color.g)); + let b = hex_component(linear_to_srgb(color.b)); + let a = clamp_unit(color.a); + if a < 1.0 { + format!("#{:02x}{:02x}{:02x}{:02x}", r, g, b, hex_component(a)) + } else { + format!("#{:02x}{:02x}{:02x}", r, g, b) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn parses_hex_colors_to_linear() { + let red = parse_color_to_linear_rgba("#ff0000").unwrap(); + assert_eq!(red.r, 1.0); + assert_eq!(red.g, 0.0); + assert_eq!(red.b, 0.0); + assert_eq!(red.a, 1.0); + + let short = parse_color_to_linear_rgba("#fff").unwrap(); + assert_eq!(short.r, 1.0); + assert_eq!(short.g, 1.0); + + let with_alpha = parse_color_to_linear_rgba("#00000080").unwrap(); + assert!((with_alpha.a - 128.0 / 255.0).abs() < 1e-9); + + assert!(parse_color_to_linear_rgba("red").is_none()); + assert!(parse_color_to_linear_rgba("#ff").is_none()); + } + + #[test] + fn formats_linear_colors_as_hex() { + let red = LinearRgba { + r: 1.0, + g: 0.0, + b: 0.0, + a: 1.0, + }; + assert_eq!(format_linear_rgba(&red), "#ff0000"); + + let translucent = LinearRgba { + r: 0.0, + g: 0.0, + b: 0.0, + a: 0.5, + }; + assert_eq!(format_linear_rgba(&translucent), "#00000080"); + } + + #[test] + fn hex_round_trips_through_linear_space() { + for color in ["#ffffff", "#000000", "#123456", "#abcdef"] { + let parsed = parse_color_to_linear_rgba(color).unwrap(); + assert_eq!(format_linear_rgba(&parsed), color); + } + } +} diff --git a/rust/crates/animation/src/id.rs b/rust/crates/animation/src/id.rs new file mode 100644 index 00000000..95288660 --- /dev/null +++ b/rust/crates/animation/src/id.rs @@ -0,0 +1,61 @@ +use std::cell::Cell; +use std::time::{SystemTime, UNIX_EPOCH}; + +thread_local! { + static RNG_STATE: Cell<u64> = const { Cell::new(0) }; +} + +fn next_u64() -> u64 { + RNG_STATE.with(|state| { + let mut value = state.get(); + if value == 0 { + let nanos = SystemTime::now() + .duration_since(UNIX_EPOCH) + .map(|duration| duration.as_nanos() as u64) + .unwrap_or(0x9e37_79b9_7f4a_7c15); + value = nanos + ^ (std::process::id() as u64).rotate_left(32) + ^ 0x9e37_79b9_7f4a_7c15; + } + value ^= value << 13; + value ^= value >> 7; + value ^= value << 17; + state.set(value); + value + }) +} + +pub fn generate_uuid() -> String { + let mut bytes = [0u8; 16]; + for chunk in bytes.chunks_mut(8) { + let random = next_u64().to_le_bytes(); + chunk.copy_from_slice(&random[..chunk.len()]); + } + bytes[6] = (bytes[6] & 0x0f) | 0x40; + bytes[8] = (bytes[8] & 0x3f) | 0x80; + let hex: Vec<String> = bytes.iter().map(|byte| format!("{byte:02x}")).collect(); + format!( + "{}-{}-{}-{}-{}", + hex[0..4].join(""), + hex[4..6].join(""), + hex[6..8].join(""), + hex[8..10].join(""), + hex[10..16].join("") + ) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn generates_uuid_shaped_unique_ids() { + let id = generate_uuid(); + let parts: Vec<&str> = id.split('-').collect(); + assert_eq!(parts.len(), 5); + assert_eq!(parts[0].len(), 8); + assert_eq!(parts[1].len(), 4); + assert_eq!(parts[4].len(), 12); + assert_ne!(generate_uuid(), generate_uuid()); + } +} diff --git a/rust/crates/animation/src/interpolation.rs b/rust/crates/animation/src/interpolation.rs new file mode 100644 index 00000000..85c0735d --- /dev/null +++ b/rust/crates/animation/src/interpolation.rs @@ -0,0 +1,467 @@ +use scene::{ + AnimationChannel, ChannelExtrapolationMode, CurveHandle, DiscreteChannel, DiscreteValue, + ParamValue, ScalarAnimationKey, ScalarChannel, ScalarSegmentType, +}; +use time::MediaTime; + +use crate::bezier::{bezier_point, segment_handles, solve_bezier_progress_for_time}; +use crate::types::AnimationInterpolation; + +pub fn is_scalar_channel(channel: &AnimationChannel) -> bool { + matches!(channel, AnimationChannel::Scalar(_)) +} + +fn normalize_right_handle( + handle: Option<CurveHandle>, + left_key: &ScalarAnimationKey, + right_key: &ScalarAnimationKey, +) -> Option<CurveHandle> { + let handle = handle?; + let span = (right_key.time - left_key.time).as_ticks().max(1); + Some(CurveHandle { + dt: MediaTime::from_ticks(handle.dt.as_ticks().clamp(0, span)), + dv: handle.dv, + }) +} + +fn normalize_left_handle( + handle: Option<CurveHandle>, + left_key: &ScalarAnimationKey, + right_key: &ScalarAnimationKey, +) -> Option<CurveHandle> { + let handle = handle?; + let span = (right_key.time - left_key.time).as_ticks().max(1); + Some(CurveHandle { + dt: MediaTime::from_ticks(handle.dt.as_ticks().clamp(-span, 0)), + dv: handle.dv, + }) +} + +pub fn normalize_scalar_channel(channel: &ScalarChannel) -> ScalarChannel { + let mut sorted_keys = channel.keys.clone(); + sorted_keys.sort_by_key(|key| key.time); + + let mut next_keys = Vec::with_capacity(sorted_keys.len()); + for (index, key) in sorted_keys.iter().enumerate() { + let previous_key = index.checked_sub(1).and_then(|i| sorted_keys.get(i)); + let next_key = sorted_keys.get(index + 1); + let left_handle = + previous_key.and_then(|previous| normalize_left_handle(key.left_handle, previous, key)); + let right_handle = + next_key.and_then(|next| normalize_right_handle(key.right_handle, key, next)); + next_keys.push(ScalarAnimationKey { + left_handle, + right_handle, + ..key.clone() + }); + } + + ScalarChannel { + keys: next_keys, + extrapolation: channel.extrapolation.clone(), + } +} + +pub fn normalize_discrete_channel(channel: &DiscreteChannel) -> DiscreteChannel { + let mut keys = channel.keys.clone(); + keys.sort_by_key(|key| key.time); + DiscreteChannel { keys } +} + +pub fn normalize_channel(channel: &AnimationChannel) -> AnimationChannel { + match channel { + AnimationChannel::Scalar(scalar) => { + AnimationChannel::Scalar(normalize_scalar_channel(scalar)) + } + AnimationChannel::Discrete(discrete) => { + AnimationChannel::Discrete(normalize_discrete_channel(discrete)) + } + } +} + +pub fn scalar_segment_interpolation(segment: ScalarSegmentType) -> AnimationInterpolation { + match segment { + ScalarSegmentType::Step => AnimationInterpolation::Hold, + ScalarSegmentType::Linear => AnimationInterpolation::Linear, + ScalarSegmentType::Bezier => AnimationInterpolation::Bezier, + } +} + +fn extrapolate_scalar_edge( + mode: ChannelExtrapolationMode, + edge_key: &ScalarAnimationKey, + neighbor_key: Option<&ScalarAnimationKey>, + time: f64, +) -> f64 { + let Some(neighbor_key) = neighbor_key else { + return edge_key.value; + }; + if mode == ChannelExtrapolationMode::Hold { + return edge_key.value; + } + + let span = (neighbor_key.time - edge_key.time).as_ticks() as f64; + if span == 0.0 { + return edge_key.value; + } + + edge_key.value + + ((time - edge_key.time.as_ticks() as f64) / span) * (neighbor_key.value - edge_key.value) +} + +fn lerp_number(left_value: f64, right_value: f64, progress: f64) -> f64 { + left_value + (right_value - left_value) * progress +} + +pub fn scalar_channel_value_at_time( + channel: Option<&ScalarChannel>, + time: MediaTime, + fallback_value: f64, +) -> f64 { + let Some(channel) = channel.filter(|channel| !channel.keys.is_empty()) else { + return fallback_value; + }; + + let normalized = normalize_scalar_channel(channel); + let keys = &normalized.keys; + let first_key = &keys[0]; + let last_key = &keys[keys.len() - 1]; + let t = time.as_ticks() as f64; + + if time <= first_key.time { + if time < first_key.time { + return extrapolate_scalar_edge( + normalized + .extrapolation + .as_ref() + .map(|e| e.before) + .unwrap_or(ChannelExtrapolationMode::Hold), + first_key, + keys.get(1), + t, + ); + } + return first_key.value; + } + + if time >= last_key.time { + if time > last_key.time { + return extrapolate_scalar_edge( + normalized + .extrapolation + .as_ref() + .map(|e| e.after) + .unwrap_or(ChannelExtrapolationMode::Hold), + last_key, + keys.len().checked_sub(2).and_then(|index| keys.get(index)), + t, + ); + } + return last_key.value; + } + + for key_index in 0..keys.len() - 1 { + let left_key = &keys[key_index]; + let right_key = &keys[key_index + 1]; + if time == right_key.time { + return right_key.value; + } + if time < left_key.time || time > right_key.time { + continue; + } + + if left_key.segment_to_next == ScalarSegmentType::Step { + return left_key.value; + } + + let span = (right_key.time - left_key.time).as_ticks() as f64; + if span == 0.0 { + return right_key.value; + } + + let progress = ((t - left_key.time.as_ticks() as f64) / span).clamp(0.0, 1.0); + if left_key.segment_to_next == ScalarSegmentType::Linear { + return lerp_number(left_key.value, right_key.value, progress); + } + + let curve_progress = solve_bezier_progress_for_time(time, left_key, right_key); + let (right_handle, left_handle) = segment_handles(left_key, right_key); + return bezier_point( + curve_progress, + left_key.value, + left_key.value + right_handle.dv, + right_key.value + left_handle.dv, + right_key.value, + ); + } + + last_key.value +} + +pub fn discrete_channel_value_at_time( + channel: Option<&DiscreteChannel>, + time: MediaTime, + fallback_value: DiscreteValue, +) -> DiscreteValue { + let Some(channel) = channel.filter(|channel| !channel.keys.is_empty()) else { + return fallback_value; + }; + + let normalized = normalize_discrete_channel(channel); + let mut current_value = fallback_value; + for key in &normalized.keys { + if time < key.time { + break; + } + current_value = key.value.clone(); + } + current_value +} + +pub fn channel_value_at_time( + channel: Option<&AnimationChannel>, + time: MediaTime, + fallback_value: &ParamValue, +) -> ParamValue { + let Some(channel) = channel.filter(|channel| !channel.keys.is_empty()) else { + return fallback_value.clone(); + }; + + if let ParamValue::Number(number_fallback) = fallback_value { + return match channel { + AnimationChannel::Scalar(scalar) => ParamValue::Number(scalar_channel_value_at_time( + Some(scalar), + time, + *number_fallback, + )), + AnimationChannel::Discrete(_) => fallback_value.clone(), + }; + } + + match channel { + AnimationChannel::Discrete(discrete) => { + let discrete_fallback = match fallback_value { + ParamValue::Bool(value) => DiscreteValue::Bool(*value), + ParamValue::String(value) => DiscreteValue::String(value.clone()), + ParamValue::Number(_) => return fallback_value.clone(), + }; + discrete_channel_value_at_time(Some(discrete), time, discrete_fallback).into() + } + AnimationChannel::Scalar(_) => fallback_value.clone(), + } +} + +#[cfg(test)] +mod tests { + use super::*; + use scene::{ChannelExtrapolation, TangentMode}; + + fn scalar_key(id: &str, time: i64, value: f64, segment: ScalarSegmentType) -> ScalarAnimationKey { + ScalarAnimationKey { + id: id.to_string(), + time: MediaTime::from_ticks(time), + value, + left_handle: None, + right_handle: None, + segment_to_next: segment, + tangent_mode: TangentMode::Flat, + } + } + + fn channel(keys: Vec<ScalarAnimationKey>) -> ScalarChannel { + ScalarChannel { + keys, + extrapolation: None, + } + } + + fn at(time: i64) -> MediaTime { + MediaTime::from_ticks(time) + } + + #[test] + fn empty_channel_returns_fallback() { + assert_eq!(scalar_channel_value_at_time(None, at(10), 7.0), 7.0); + assert_eq!( + scalar_channel_value_at_time(Some(&channel(Vec::new())), at(10), 7.0), + 7.0 + ); + } + + #[test] + fn linear_segment_interpolates() { + let channel = channel(vec![ + scalar_key("a", 0, 0.0, ScalarSegmentType::Linear), + scalar_key("b", 100, 10.0, ScalarSegmentType::Linear), + ]); + assert_eq!(scalar_channel_value_at_time(Some(&channel), at(50), 0.0), 5.0); + assert_eq!(scalar_channel_value_at_time(Some(&channel), at(0), 0.0), 0.0); + assert_eq!(scalar_channel_value_at_time(Some(&channel), at(100), 0.0), 10.0); + } + + #[test] + fn step_segment_holds_left_value() { + let channel = channel(vec![ + scalar_key("a", 0, 1.0, ScalarSegmentType::Step), + scalar_key("b", 100, 9.0, ScalarSegmentType::Linear), + ]); + assert_eq!(scalar_channel_value_at_time(Some(&channel), at(99), 0.0), 1.0); + assert_eq!(scalar_channel_value_at_time(Some(&channel), at(100), 0.0), 9.0); + } + + #[test] + fn unsorted_keys_are_sorted_before_sampling() { + let channel = channel(vec![ + scalar_key("b", 100, 10.0, ScalarSegmentType::Linear), + scalar_key("a", 0, 0.0, ScalarSegmentType::Linear), + ]); + assert_eq!(scalar_channel_value_at_time(Some(&channel), at(50), 0.0), 5.0); + } + + #[test] + fn hold_extrapolation_is_the_default() { + let channel = channel(vec![ + scalar_key("a", 10, 1.0, ScalarSegmentType::Linear), + scalar_key("b", 100, 10.0, ScalarSegmentType::Linear), + ]); + assert_eq!(scalar_channel_value_at_time(Some(&channel), at(0), 0.0), 1.0); + assert_eq!(scalar_channel_value_at_time(Some(&channel), at(200), 0.0), 10.0); + } + + #[test] + fn linear_extrapolation_extends_edge_segments() { + let mut channel = channel(vec![ + scalar_key("a", 0, 0.0, ScalarSegmentType::Linear), + scalar_key("b", 100, 10.0, ScalarSegmentType::Linear), + ]); + channel.extrapolation = Some(ChannelExtrapolation { + before: ChannelExtrapolationMode::Linear, + after: ChannelExtrapolationMode::Linear, + }); + assert_eq!(scalar_channel_value_at_time(Some(&channel), at(-50), 0.0), -5.0); + assert_eq!(scalar_channel_value_at_time(Some(&channel), at(150), 0.0), 15.0); + } + + #[test] + fn linear_extrapolation_holds_with_single_key() { + let mut channel = channel(vec![scalar_key("a", 10, 3.0, ScalarSegmentType::Linear)]); + channel.extrapolation = Some(ChannelExtrapolation { + before: ChannelExtrapolationMode::Linear, + after: ChannelExtrapolationMode::Linear, + }); + assert_eq!(scalar_channel_value_at_time(Some(&channel), at(0), 0.0), 3.0); + assert_eq!(scalar_channel_value_at_time(Some(&channel), at(50), 0.0), 3.0); + } + + #[test] + fn bezier_segment_with_default_handles_matches_diagonal() { + let channel = channel(vec![ + scalar_key("a", 0, 0.0, ScalarSegmentType::Bezier), + 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); + } + + #[test] + fn bezier_segment_with_flat_handles_eases() { + let mut left = scalar_key("a", 0, 0.0, ScalarSegmentType::Bezier); + left.right_handle = Some(CurveHandle { + dt: at(50), + dv: 0.0, + }); + let mut right = scalar_key("b", 100, 100.0, ScalarSegmentType::Bezier); + right.left_handle = Some(CurveHandle { + dt: at(-50), + dv: 0.0, + }); + let channel = channel(vec![left, right]); + 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); + } + + #[test] + fn handles_are_clamped_to_segment_span() { + let mut left = scalar_key("a", 0, 0.0, ScalarSegmentType::Bezier); + left.right_handle = Some(CurveHandle { + dt: at(500), + dv: 1.0, + }); + let mut right = scalar_key("b", 100, 100.0, ScalarSegmentType::Bezier); + right.left_handle = Some(CurveHandle { + dt: at(50), + dv: 1.0, + }); + let normalized = normalize_scalar_channel(&channel(vec![left, right])); + assert_eq!(normalized.keys[0].right_handle.unwrap().dt, at(100)); + assert_eq!(normalized.keys[1].left_handle.unwrap().dt, at(0)); + } + + #[test] + fn edge_keys_drop_outer_handles() { + let mut left = scalar_key("a", 0, 0.0, ScalarSegmentType::Bezier); + left.left_handle = Some(CurveHandle { + dt: at(-10), + dv: 1.0, + }); + let mut right = scalar_key("b", 100, 100.0, ScalarSegmentType::Bezier); + right.right_handle = Some(CurveHandle { + dt: at(10), + dv: 1.0, + }); + let normalized = normalize_scalar_channel(&channel(vec![left, right])); + assert_eq!(normalized.keys[0].left_handle, None); + assert_eq!(normalized.keys[1].right_handle, None); + } + + #[test] + fn discrete_channel_returns_last_key_at_or_before_time() { + let channel = 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::Bool(true), + }, + ], + }; + let fallback = DiscreteValue::Bool(false); + assert_eq!( + discrete_channel_value_at_time(Some(&channel), at(-1), fallback.clone()), + fallback + ); + assert_eq!( + discrete_channel_value_at_time(Some(&channel), at(50), fallback.clone()), + DiscreteValue::String("x".to_string()) + ); + assert_eq!( + discrete_channel_value_at_time(Some(&channel), at(100), fallback.clone()), + DiscreteValue::Bool(true) + ); + } + + #[test] + fn channel_value_at_time_dispatches_on_fallback_type() { + let scalar = AnimationChannel::Scalar(channel(vec![scalar_key( + "a", + 0, + 2.0, + ScalarSegmentType::Linear, + )])); + assert_eq!( + channel_value_at_time(Some(&scalar), at(0), &ParamValue::Number(0.0)), + ParamValue::Number(2.0) + ); + assert_eq!( + channel_value_at_time(Some(&scalar), at(0), &ParamValue::Bool(true)), + ParamValue::Bool(true) + ); + } +} diff --git a/rust/crates/animation/src/keyframes.rs b/rust/crates/animation/src/keyframes.rs new file mode 100644 index 00000000..0ce938f9 --- /dev/null +++ b/rust/crates/animation/src/keyframes.rs @@ -0,0 +1,1150 @@ +use std::collections::{BTreeMap, HashMap}; + +use scene::{ + AnimationChannel, ChannelData, DiscreteAnimationKey, DiscreteChannel, DiscreteValue, + ElementAnimations, ParamValue, ScalarAnimationKey, ScalarChannel, ScalarSegmentType, + TangentMode, +}; +use time::MediaTime; + +use crate::bezier::{segment_handles, solve_bezier_progress_for_time}; +use crate::channel_data::{ + get_channel_entries_from_data, get_channels_from_data, is_animation_storage_key, +}; +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, +}; +use crate::types::{AnimationInterpolation, ScalarCurveKeyframePatch}; + +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; +} + +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_data(data: Option<&ChannelData>) -> bool { + get_channels_from_data(data) + .iter() + .any(|channel| has_channel_keys(Some(channel))) +} + +pub fn to_animation(animations: ElementAnimations) -> Option<ElementAnimations> { + let next: ElementAnimations = animations + .into_iter() + .filter(|(key, data)| is_animation_storage_key(key) && has_channel_data(Some(data))) + .collect(); + if next.is_empty() { None } else { Some(next) } +} + +fn get_channel_from_data<'a>( + data: Option<&'a ChannelData>, + component_key: &str, +) -> Option<&'a AnimationChannel> { + match data { + Some(ChannelData::Single(channel)) => { + if component_key == "value" { Some(channel) } else { None } + } + Some(ChannelData::Composite(components)) => components.get(component_key), + None => None, + } +} + +fn get_primary_component_key(channel_layout: &ChannelLayout) -> String { + channel_layout + .components() + .first() + .map(|component| component.key.clone()) + .unwrap_or_else(|| "value".to_string()) +} + +fn get_primary_channel_from_data<'a>( + data: Option<&'a ChannelData>, + channel_layout: &ChannelLayout, +) -> Option<&'a AnimationChannel> { + get_channel_from_data(data, &get_primary_component_key(channel_layout)) +} + +fn set_channel_in_data( + data: Option<ChannelData>, + component_key: &str, + channel: Option<AnimationChannel>, +) -> Option<ChannelData> { + if component_key == "value" { + return channel.map(ChannelData::Single); + } + + let mut components = match data { + Some(ChannelData::Composite(components)) => components, + _ => BTreeMap::new(), + }; + match channel { + Some(channel) if has_channel_keys(Some(&channel)) => { + components.insert(component_key.to_string(), channel); + } + _ => { + components.remove(component_key); + } + } + if components.is_empty() { + None + } else { + Some(ChannelData::Composite(components)) + } +} + +fn scalar_segment_type(interpolation: AnimationInterpolation) -> ScalarSegmentType { + match interpolation { + AnimationInterpolation::Hold => ScalarSegmentType::Step, + AnimationInterpolation::Bezier => ScalarSegmentType::Bezier, + AnimationInterpolation::Linear => ScalarSegmentType::Linear, + } +} + +fn create_scalar_key( + id: String, + time: MediaTime, + value: f64, + interpolation: Option<AnimationInterpolation>, + previous_key: Option<&ScalarAnimationKey>, +) -> ScalarAnimationKey { + ScalarAnimationKey { + id, + time, + value, + left_handle: previous_key.and_then(|key| key.left_handle), + right_handle: previous_key.and_then(|key| key.right_handle), + segment_to_next: previous_key.map(|key| key.segment_to_next).unwrap_or_else(|| { + scalar_segment_type(interpolation.unwrap_or(AnimationInterpolation::Linear)) + }), + tangent_mode: previous_key + .map(|key| key.tangent_mode) + .unwrap_or(TangentMode::Flat), + } +} + +fn channel_key_ids_and_times(channel: &AnimationChannel) -> Vec<(String, MediaTime)> { + match channel { + AnimationChannel::Scalar(scalar) => scalar + .keys + .iter() + .map(|key| (key.id.clone(), key.time)) + .collect(), + AnimationChannel::Discrete(discrete) => discrete + .keys + .iter() + .map(|key| (key.id.clone(), key.time)) + .collect(), + } +} + +fn get_target_key_metadata( + channel: Option<&AnimationChannel>, + time: MediaTime, + keyframe_id: Option<&str>, +) -> (String, MediaTime) { + let normalized = channel.map(normalize_channel); + let keys = normalized + .as_ref() + .map(channel_key_ids_and_times) + .unwrap_or_default(); + + if let Some(id) = keyframe_id { + if let Some((found_id, _)) = keys.iter().find(|(key_id, _)| key_id == id) { + return (found_id.clone(), time); + } + } + + if let Some((found_id, found_time)) = keys.iter().find(|(_, key_time)| *key_time == time) { + return (found_id.clone(), *found_time); + } + + ( + keyframe_id.map(str::to_string).unwrap_or_else(generate_uuid), + time, + ) +} + +pub fn upsert_discrete_channel_key( + channel: Option<&DiscreteChannel>, + time: MediaTime, + value: DiscreteValue, + keyframe_id: Option<&str>, +) -> DiscreteChannel { + let normalized = normalize_discrete_channel(&channel.cloned().unwrap_or(DiscreteChannel { + keys: Vec::new(), + })); + let mut keys = normalized.keys; + + if let Some(id) = keyframe_id { + if let Some(index) = keys.iter().position(|key| key.id == id) { + keys[index] = DiscreteAnimationKey { + id: keys[index].id.clone(), + time, + value, + }; + return normalize_discrete_channel(&DiscreteChannel { keys }); + } + } + + if let Some(index) = keys.iter().position(|key| key.time == time) { + keys[index] = DiscreteAnimationKey { + id: keys[index].id.clone(), + time: keys[index].time, + value, + }; + return normalize_discrete_channel(&DiscreteChannel { keys }); + } + + keys.push(DiscreteAnimationKey { + id: keyframe_id.map(str::to_string).unwrap_or_else(generate_uuid), + time, + value, + }); + normalize_discrete_channel(&DiscreteChannel { keys }) +} + +fn replace_scalar_key_at( + keys: &mut [ScalarAnimationKey], + index: usize, + time: MediaTime, + value: f64, + interpolation: Option<AnimationInterpolation>, + keep_existing_time: bool, +) { + let mut previous_key = keys[index].clone(); + if let Some(interpolation) = interpolation { + previous_key.segment_to_next = scalar_segment_type(interpolation); + } + let key_time = if keep_existing_time { + previous_key.time + } else { + time + }; + keys[index] = create_scalar_key( + previous_key.id.clone(), + key_time, + value, + interpolation, + Some(&previous_key), + ); +} + +pub fn upsert_scalar_channel_key( + channel: Option<&ScalarChannel>, + time: MediaTime, + value: f64, + interpolation: Option<AnimationInterpolation>, + default_interpolation: Option<AnimationInterpolation>, + keyframe_id: Option<&str>, +) -> ScalarChannel { + let normalized = normalize_scalar_channel(&channel.cloned().unwrap_or(ScalarChannel { + keys: Vec::new(), + extrapolation: None, + })); + let mut keys = normalized.keys; + + if let Some(id) = keyframe_id { + if let Some(index) = keys.iter().position(|key| key.id == id) { + replace_scalar_key_at(&mut keys, index, time, value, interpolation, false); + return normalize_scalar_channel(&ScalarChannel { + keys, + extrapolation: normalized.extrapolation, + }); + } + } + + if let Some(index) = keys.iter().position(|key| key.time == time) { + replace_scalar_key_at(&mut keys, index, time, value, interpolation, true); + return normalize_scalar_channel(&ScalarChannel { + keys, + extrapolation: normalized.extrapolation, + }); + } + + keys.push(create_scalar_key( + keyframe_id.map(str::to_string).unwrap_or_else(generate_uuid), + time, + value, + interpolation.or(default_interpolation), + None, + )); + normalize_scalar_channel(&ScalarChannel { + keys, + extrapolation: normalized.extrapolation, + }) +} + +pub fn get_channel<'a>( + animations: Option<&'a ElementAnimations>, + property_path: &str, +) -> Option<&'a AnimationChannel> { + let data = animations?.get(property_path); + match data { + ChannelData::Single(channel) => Some(channel), + ChannelData::Composite(_) => get_channels_from_data(Some(data)).into_iter().next(), + } +} + +pub fn upsert_path_keyframe( + animations: Option<&ElementAnimations>, + property_path: &str, + time: MediaTime, + value: &ParamValue, + interpolation: Option<AnimationInterpolation>, + keyframe_id: Option<&str>, + channel_layout: &ChannelLayout, + coerce_value: impl Fn(&ParamValue) -> Option<ParamValue>, +) -> Option<ElementAnimations> { + let coerced_value = coerce_value(value)?; + + 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 mut next_data = current_data.clone(); + for component in channel_layout.components() { + let component_key = component.key.as_str(); + let Some(next_value) = component_values.get(component_key) else { + continue; + }; + let current_channel = get_channel_from_data(current_data.as_ref(), component_key); + + match component.value_kind { + ChannelValueKind::Discrete => { + let discrete_value = match next_value { + ParamValue::String(value) => DiscreteValue::String(value.clone()), + ParamValue::Bool(value) => DiscreteValue::Bool(*value), + ParamValue::Number(_) => continue, + }; + let discrete_channel = match current_channel { + Some(AnimationChannel::Discrete(discrete)) => Some(discrete), + _ => None, + }; + let next_channel = upsert_discrete_channel_key( + discrete_channel, + target_time, + discrete_value, + Some(&target_id), + ); + next_data = set_channel_in_data( + next_data, + component_key, + Some(AnimationChannel::Discrete(next_channel)), + ); + } + ChannelValueKind::Scalar => { + let ParamValue::Number(number_value) = next_value else { + continue; + }; + let scalar_channel = match current_channel { + Some(AnimationChannel::Scalar(scalar)) => Some(scalar), + _ => None, + }; + let next_channel = upsert_scalar_channel_key( + scalar_channel, + target_time, + *number_value, + interpolation, + Some(component.default_interpolation), + Some(&target_id), + ); + next_data = set_channel_in_data( + next_data, + component_key, + Some(AnimationChannel::Scalar(next_channel)), + ); + } + } + } + + match next_data { + Some(data) => { + next_animations.insert(property_path.to_string(), data); + } + None => { + next_animations.remove(property_path); + } + } + to_animation(next_animations) +} + +pub fn upsert_keyframe( + channel: Option<&AnimationChannel>, + time: MediaTime, + value: &ParamValue, + interpolation: Option<AnimationInterpolation>, + keyframe_id: Option<&str>, +) -> Option<AnimationChannel> { + let channel = channel?; + + match value { + ParamValue::String(value) => { + let discrete = match channel { + AnimationChannel::Discrete(discrete) => Some(discrete), + _ => None, + }; + Some(AnimationChannel::Discrete(upsert_discrete_channel_key( + discrete, + time, + DiscreteValue::String(value.clone()), + keyframe_id, + ))) + } + ParamValue::Bool(value) => { + let discrete = match channel { + AnimationChannel::Discrete(discrete) => Some(discrete), + _ => None, + }; + Some(AnimationChannel::Discrete(upsert_discrete_channel_key( + discrete, + time, + DiscreteValue::Bool(*value), + keyframe_id, + ))) + } + ParamValue::Number(value) => { + let scalar = match channel { + AnimationChannel::Scalar(scalar) => Some(scalar), + _ => None, + }; + Some(AnimationChannel::Scalar(upsert_scalar_channel_key( + scalar, + time, + *value, + interpolation, + None, + keyframe_id, + ))) + } + } +} + +pub fn remove_keyframe( + channel: Option<&AnimationChannel>, + keyframe_id: &str, +) -> Option<AnimationChannel> { + let channel = channel?; + + match channel { + AnimationChannel::Scalar(scalar) => { + let keys: Vec<ScalarAnimationKey> = scalar + .keys + .iter() + .filter(|key| key.id != keyframe_id) + .cloned() + .collect(); + if keys.is_empty() { + return None; + } + Some(AnimationChannel::Scalar(normalize_scalar_channel( + &ScalarChannel { + keys, + extrapolation: scalar.extrapolation.clone(), + }, + ))) + } + AnimationChannel::Discrete(discrete) => { + let keys: Vec<DiscreteAnimationKey> = discrete + .keys + .iter() + .filter(|key| key.id != keyframe_id) + .cloned() + .collect(); + if keys.is_empty() { + return None; + } + Some(AnimationChannel::Discrete(normalize_discrete_channel( + &DiscreteChannel { keys }, + ))) + } + } +} + +pub fn retime_keyframe( + channel: Option<&AnimationChannel>, + keyframe_id: &str, + time: MediaTime, +) -> Option<AnimationChannel> { + let channel = channel?; + + match channel { + AnimationChannel::Scalar(scalar) => { + let Some(index) = scalar.keys.iter().position(|key| key.id == keyframe_id) else { + return Some(channel.clone()); + }; + let mut keys = scalar.keys.clone(); + keys[index].time = time; + Some(AnimationChannel::Scalar(normalize_scalar_channel( + &ScalarChannel { + keys, + extrapolation: scalar.extrapolation.clone(), + }, + ))) + } + AnimationChannel::Discrete(discrete) => { + let Some(index) = discrete.keys.iter().position(|key| key.id == keyframe_id) else { + return Some(channel.clone()); + }; + let mut keys = discrete.keys.clone(); + keys[index].time = time; + Some(AnimationChannel::Discrete(normalize_discrete_channel( + &DiscreteChannel { keys }, + ))) + } + } +} + +pub fn set_channel( + animations: Option<&ElementAnimations>, + property_path: &str, + channel: Option<&AnimationChannel>, +) -> Option<ElementAnimations> { + set_binding_component_channel(animations, property_path, "value", channel) +} + +pub fn set_binding_component_channel( + animations: Option<&ElementAnimations>, + property_path: &str, + component_key: &str, + channel: Option<&AnimationChannel>, +) -> Option<ElementAnimations> { + let mut next_animations = animations.cloned().unwrap_or_default(); + let prepared = channel + .filter(|channel| has_channel_keys(Some(channel))) + .map(normalize_channel); + let data = set_channel_in_data( + next_animations.get(property_path).cloned(), + component_key, + prepared, + ); + match data { + Some(data) => { + next_animations.insert(property_path.to_string(), data); + } + None => { + next_animations.remove(property_path); + } + } + to_animation(next_animations) +} + +pub fn update_scalar_keyframe_curve( + animations: Option<&ElementAnimations>, + property_path: &str, + component_key: &str, + keyframe_id: &str, + patch: &ScalarCurveKeyframePatch, +) -> Option<ElementAnimations> { + let channel = get_channel_from_data( + animations.and_then(|a| a.get(property_path)), + component_key, + ); + let Some(AnimationChannel::Scalar(scalar)) = channel else { + return animations.cloned(); + }; + + let Some(keyframe_index) = scalar.keys.iter().position(|key| key.id == keyframe_id) else { + return animations.cloned(); + }; + + let mut keys = scalar.keys.clone(); + let current_key = &keys[keyframe_index]; + keys[keyframe_index] = ScalarAnimationKey { + left_handle: patch.left_handle.unwrap_or(current_key.left_handle), + right_handle: patch.right_handle.unwrap_or(current_key.right_handle), + segment_to_next: patch.segment_to_next.unwrap_or(current_key.segment_to_next), + tangent_mode: patch.tangent_mode.unwrap_or(current_key.tangent_mode), + ..current_key.clone() + }; + + set_binding_component_channel( + animations, + property_path, + component_key, + Some(&AnimationChannel::Scalar(ScalarChannel { + keys, + extrapolation: scalar.extrapolation.clone(), + })), + ) +} + +fn clone_channel_with_key_ids( + channel: &AnimationChannel, + key_id_map: &HashMap<String, String>, +) -> AnimationChannel { + let remap = |id: &str| key_id_map.get(id).cloned().unwrap_or_else(|| id.to_string()); + match channel { + AnimationChannel::Scalar(scalar) => AnimationChannel::Scalar(normalize_scalar_channel( + &ScalarChannel { + keys: scalar + .keys + .iter() + .map(|key| ScalarAnimationKey { + id: remap(&key.id), + ..key.clone() + }) + .collect(), + extrapolation: scalar.extrapolation.clone(), + }, + )), + AnimationChannel::Discrete(discrete) => { + AnimationChannel::Discrete(normalize_discrete_channel(&DiscreteChannel { + keys: discrete + .keys + .iter() + .map(|key| DiscreteAnimationKey { + id: remap(&key.id), + ..key.clone() + }) + .collect(), + })) + } + } +} + +pub fn clone_animations( + animations: Option<&ElementAnimations>, + regenerate_keyframe_ids: bool, +) -> Option<ElementAnimations> { + let animations = animations?; + + let mut next_animations = animations.clone(); + for (property_path, data) in animations + .iter() + .filter(|(key, _)| is_animation_storage_key(key)) + { + let channels = get_channels_from_data(Some(data)); + let mut key_id_map = HashMap::new(); + if let Some(primary_channel) = channels.first() { + for (id, _) in channel_key_ids_and_times(primary_channel) { + let next_id = if regenerate_keyframe_ids { + generate_uuid() + } else { + id.clone() + }; + key_id_map.insert(id, next_id); + } + } + + let next_data = match data { + ChannelData::Single(channel) => { + ChannelData::Single(clone_channel_with_key_ids(channel, &key_id_map)) + } + ChannelData::Composite(components) => ChannelData::Composite( + components + .iter() + .map(|(component_key, channel)| { + ( + component_key.clone(), + clone_channel_with_key_ids(channel, &key_id_map), + ) + }) + .collect(), + ), + }; + next_animations.insert(property_path.clone(), next_data); + } + + to_animation(next_animations) +} + +pub fn clamp_animations_to_duration( + animations: Option<&ElementAnimations>, + duration: MediaTime, +) -> Option<ElementAnimations> { + let animations = animations?; + if duration <= MediaTime::ZERO { + return None; + } + split_animations_at_time_with_options(Some(animations), duration, true).0 +} + +#[derive(Clone, Copy, Debug)] +struct Point { + x: f64, + y: f64, +} + +fn lerp_point(left: Point, right: Point, progress: f64) -> Point { + Point { + x: left.x + (right.x - left.x) * progress, + y: left.y + (right.y - left.y) * progress, + } +} + +pub struct SplitChannelResult { + pub left_channel: Option<AnimationChannel>, + pub right_channel: Option<AnimationChannel>, +} + +fn split_discrete_channel_at_time( + channel: Option<&DiscreteChannel>, + split_time: MediaTime, + left_boundary_id: &str, + right_boundary_id: &str, + include_split_boundary: bool, +) -> SplitChannelResult { + let Some(channel) = channel.filter(|channel| !channel.keys.is_empty()) else { + return SplitChannelResult { + left_channel: None, + right_channel: None, + }; + }; + + let normalized = normalize_discrete_channel(channel); + let mut left_keys: Vec<DiscreteAnimationKey> = normalized + .keys + .iter() + .filter(|key| key.time <= split_time) + .cloned() + .collect(); + let mut right_keys: Vec<DiscreteAnimationKey> = normalized + .keys + .iter() + .filter(|key| key.time >= split_time) + .map(|key| DiscreteAnimationKey { + time: key.time - split_time, + ..key.clone() + }) + .collect(); + + 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, + split_time, + normalized.keys[0].value.clone(), + ); + if !has_boundary_on_left { + left_keys.push(DiscreteAnimationKey { + id: left_boundary_id.to_string(), + time: split_time, + value: boundary_value.clone(), + }); + } + if !has_boundary_on_right { + right_keys.insert( + 0, + DiscreteAnimationKey { + id: right_boundary_id.to_string(), + time: MediaTime::ZERO, + value: boundary_value, + }, + ); + } + } + + SplitChannelResult { + left_channel: if left_keys.is_empty() { + None + } else { + Some(AnimationChannel::Discrete(normalize_discrete_channel( + &DiscreteChannel { keys: left_keys }, + ))) + }, + right_channel: if right_keys.is_empty() { + None + } else { + Some(AnimationChannel::Discrete(normalize_discrete_channel( + &DiscreteChannel { keys: right_keys }, + ))) + }, + } +} + +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>, + extrapolation: Option<scene::ChannelExtrapolation>, +) -> SplitChannelResult { + SplitChannelResult { + left_channel: if left_keys.is_empty() { + None + } else { + Some(AnimationChannel::Scalar(normalize_scalar_channel( + &ScalarChannel { + keys: left_keys, + extrapolation: extrapolation.clone(), + }, + ))) + }, + right_channel: if right_keys.is_empty() { + None + } else { + Some(AnimationChannel::Scalar(normalize_scalar_channel( + &ScalarChannel { + keys: right_keys, + extrapolation, + }, + ))) + }, + } +} + +fn split_scalar_channel_at_time( + channel: Option<&ScalarChannel>, + split_time: MediaTime, + left_boundary_id: &str, + right_boundary_id: &str, + include_split_boundary: bool, +) -> SplitChannelResult { + let Some(channel) = channel.filter(|channel| !channel.keys.is_empty()) else { + return SplitChannelResult { + left_channel: None, + right_channel: None, + }; + }; + + let normalized = normalize_scalar_channel(channel); + let mut left_keys: Vec<ScalarAnimationKey> = normalized + .keys + .iter() + .filter(|key| key.time <= split_time) + .cloned() + .collect(); + let mut right_keys: Vec<ScalarAnimationKey> = normalized + .keys + .iter() + .filter(|key| key.time >= split_time) + .map(|key| ScalarAnimationKey { + time: key.time - split_time, + ..key.clone() + }) + .collect(); + + 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); + if !include_split_boundary || (has_boundary_on_left && has_boundary_on_right) { + return build_split_scalar_result( + left_keys, + right_keys, + normalized.extrapolation.clone(), + ); + } + + for key_index in 0..normalized.keys.len().saturating_sub(1) { + let left_key = &normalized.keys[key_index]; + let right_key = &normalized.keys[key_index + 1]; + if split_time <= left_key.time || split_time >= right_key.time { + continue; + } + + let boundary_value = crate::interpolation::scalar_channel_value_at_time( + Some(&normalized), + split_time, + left_key.value, + ); + + if left_key.segment_to_next == ScalarSegmentType::Bezier { + let (right_handle, left_handle) = segment_handles(left_key, right_key); + let progress = solve_bezier_progress_for_time(split_time, left_key, right_key); + let p0 = Point { + x: left_key.time.as_ticks() as f64, + y: left_key.value, + }; + let p1 = Point { + x: p0.x + right_handle.dt, + y: p0.y + right_handle.dv, + }; + let p3 = Point { + x: right_key.time.as_ticks() as f64, + y: right_key.value, + }; + let p2 = Point { + x: p3.x + left_handle.dt, + y: p3.y + left_handle.dv, + }; + let q0 = lerp_point(p0, p1, progress); + let q1 = lerp_point(p1, p2, progress); + let q2 = lerp_point(p2, p3, progress); + let r0 = lerp_point(q0, q1, progress); + let r1 = lerp_point(q1, q2, progress); + let split_point = lerp_point(r0, r1, progress); + + let mut next_left_keys: Vec<ScalarAnimationKey> = normalized + .keys + .iter() + .filter(|key| key.time < split_time) + .cloned() + .collect(); + next_left_keys.push(ScalarAnimationKey { + right_handle: Some(scene::CurveHandle { + dt: round_media_time(q0.x - p0.x), + dv: q0.y - p0.y, + }), + ..left_key.clone() + }); + next_left_keys.push(ScalarAnimationKey { + id: left_boundary_id.to_string(), + time: split_time, + value: boundary_value, + left_handle: Some(scene::CurveHandle { + dt: round_media_time(r0.x - split_point.x), + dv: r0.y - split_point.y, + }), + right_handle: None, + segment_to_next: left_key.segment_to_next, + tangent_mode: left_key.tangent_mode, + }); + + let mut next_right_keys: Vec<ScalarAnimationKey> = vec![ + ScalarAnimationKey { + id: right_boundary_id.to_string(), + time: MediaTime::ZERO, + value: boundary_value, + left_handle: None, + right_handle: Some(scene::CurveHandle { + dt: round_media_time(r1.x - split_point.x), + dv: r1.y - split_point.y, + }), + segment_to_next: ScalarSegmentType::Bezier, + tangent_mode: left_key.tangent_mode, + }, + ScalarAnimationKey { + time: right_key.time - split_time, + left_handle: Some(scene::CurveHandle { + dt: round_media_time(q2.x - p3.x), + dv: q2.y - p3.y, + }), + ..right_key.clone() + }, + ]; + next_right_keys.extend( + normalized + .keys + .iter() + .filter(|key| key.time > right_key.time) + .map(|key| ScalarAnimationKey { + time: key.time - split_time, + ..key.clone() + }), + ); + + return SplitChannelResult { + left_channel: Some(AnimationChannel::Scalar(normalize_scalar_channel( + &ScalarChannel { + keys: next_left_keys, + extrapolation: normalized.extrapolation.clone(), + }, + ))), + right_channel: Some(AnimationChannel::Scalar(normalize_scalar_channel( + &ScalarChannel { + keys: next_right_keys, + extrapolation: normalized.extrapolation.clone(), + }, + ))), + }; + } + + left_keys.push(create_scalar_key( + left_boundary_id.to_string(), + split_time, + boundary_value, + Some(AnimationInterpolation::Linear), + None, + )); + right_keys.insert( + 0, + create_scalar_key( + right_boundary_id.to_string(), + MediaTime::ZERO, + boundary_value, + Some(scalar_segment_interpolation(left_key.segment_to_next)), + None, + ), + ); + return build_split_scalar_result( + left_keys, + right_keys, + normalized.extrapolation.clone(), + ); + } + + build_split_scalar_result(left_keys, right_keys, normalized.extrapolation.clone()) +} + +fn split_channel_at_time( + channel: Option<&AnimationChannel>, + split_time: MediaTime, + left_boundary_id: &str, + right_boundary_id: &str, + include_split_boundary: bool, +) -> SplitChannelResult { + match channel { + Some(AnimationChannel::Discrete(discrete)) => split_discrete_channel_at_time( + Some(discrete), + split_time, + left_boundary_id, + right_boundary_id, + include_split_boundary, + ), + scalar => split_scalar_channel_at_time( + match scalar { + Some(AnimationChannel::Scalar(scalar)) => Some(scalar), + _ => None, + }, + split_time, + left_boundary_id, + right_boundary_id, + include_split_boundary, + ), + } +} + +pub fn split_animations_at_time_with_options( + animations: Option<&ElementAnimations>, + split_time: MediaTime, + include_split_boundary: bool, +) -> (Option<ElementAnimations>, Option<ElementAnimations>) { + let Some(animations) = animations else { + return (None, None); + }; + + let mut left_animations = ElementAnimations::new(); + let mut right_animations = ElementAnimations::new(); + + for (property_path, data) in animations + .iter() + .filter(|(key, _)| is_animation_storage_key(key)) + { + let left_boundary_id = generate_uuid(); + let right_boundary_id = generate_uuid(); + + for (component_key, channel) in get_channel_entries_from_data(Some(data)) { + let split = split_channel_at_time( + Some(channel), + split_time, + &left_boundary_id, + &right_boundary_id, + include_split_boundary, + ); + if let Some(left_channel) = split.left_channel { + if let Some(data) = set_channel_in_data( + left_animations.get(property_path).cloned(), + &component_key, + Some(left_channel), + ) { + left_animations.insert(property_path.clone(), data); + } + } + if let Some(right_channel) = split.right_channel { + if let Some(data) = set_channel_in_data( + right_animations.get(property_path).cloned(), + &component_key, + Some(right_channel), + ) { + right_animations.insert(property_path.clone(), data); + } + } + } + } + + (to_animation(left_animations), to_animation(right_animations)) +} + +pub fn split_animations_at_time( + animations: &ElementAnimations, + split_time: MediaTime, +) -> (ElementAnimations, ElementAnimations) { + let (left, right) = split_animations_at_time_with_options(Some(animations), split_time, true); + (left.unwrap_or_default(), right.unwrap_or_default()) +} + +pub fn remove_element_keyframe( + animations: Option<&ElementAnimations>, + property_path: &str, + keyframe_id: &str, +) -> Option<ElementAnimations> { + let data = animations.and_then(|a| a.get(property_path))?; + + let mut next_animations = animations.cloned().unwrap_or_default(); + let next_data = match data { + ChannelData::Single(channel) => remove_keyframe(Some(channel), keyframe_id), + ChannelData::Composite(components) => { + let mut next_data = Some(data.clone()); + for (component_key, channel) in components { + next_data = set_channel_in_data( + next_data, + component_key, + remove_keyframe(Some(channel), keyframe_id), + ); + } + next_data + } + }; + match next_data { + Some(data) => { + next_animations.insert(property_path.to_string(), data); + } + None => { + next_animations.remove(property_path); + } + } + to_animation(next_animations) +} + +pub fn retime_element_keyframe( + animations: Option<&ElementAnimations>, + property_path: &str, + keyframe_id: &str, + time: MediaTime, +) -> Option<ElementAnimations> { + let data = animations.and_then(|a| a.get(property_path))?; + + 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::Composite(components) => { + let mut next_data = Some(data.clone()); + for (component_key, channel) in components { + next_data = set_channel_in_data( + next_data, + component_key, + retime_keyframe(Some(channel), keyframe_id, time), + ); + } + next_data + } + }; + match next_data { + Some(data) => { + next_animations.insert(property_path.to_string(), data); + } + None => { + next_animations.remove(property_path); + } + } + to_animation(next_animations) +} diff --git a/rust/crates/animation/src/lib.rs b/rust/crates/animation/src/lib.rs new file mode 100644 index 00000000..8b137891 --- /dev/null +++ b/rust/crates/animation/src/lib.rs @@ -0,0 +1 @@ + diff --git a/rust/crates/animation/src/types.rs b/rust/crates/animation/src/types.rs new file mode 100644 index 00000000..ca0c3c20 --- /dev/null +++ b/rust/crates/animation/src/types.rs @@ -0,0 +1,45 @@ +use scene::{CurveHandle, ParamValue, ScalarSegmentType, TangentMode}; +use serde::{Deserialize, Serialize}; +use time::MediaTime; + +#[derive(Serialize, Deserialize, Clone, Copy, Debug, Eq, PartialEq)] +#[serde(rename_all = "camelCase")] +pub enum AnimationInterpolation { + Linear, + Hold, + Bezier, +} + +#[derive(Serialize, Deserialize, Clone, Copy, Debug, Default, PartialEq)] +#[serde(rename_all = "camelCase")] +pub struct NumericSpec { + #[serde(default, skip_serializing_if = "Option::is_none")] + pub min: Option<f64>, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub max: Option<f64>, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub step: Option<f64>, +} + +#[derive(Serialize, Deserialize, Clone, Copy, Debug, Default, PartialEq)] +#[serde(rename_all = "camelCase")] +pub struct ScalarCurveKeyframePatch { + #[serde(default, skip_serializing_if = "Option::is_none")] + pub left_handle: Option<Option<CurveHandle>>, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub right_handle: Option<Option<CurveHandle>>, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub segment_to_next: Option<ScalarSegmentType>, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub tangent_mode: Option<TangentMode>, +} + +#[derive(Serialize, Deserialize, Clone, Debug, PartialEq)] +#[serde(rename_all = "camelCase")] +pub struct ElementKeyframe { + pub property_path: String, + pub id: String, + pub time: MediaTime, + pub value: ParamValue, + pub interpolation: AnimationInterpolation, +} diff --git a/rust/crates/commands/Cargo.toml b/rust/crates/commands/Cargo.toml new file mode 100644 index 00000000..ef5bc895 --- /dev/null +++ b/rust/crates/commands/Cargo.toml @@ -0,0 +1,15 @@ +[package] +name = "commands" +version = "0.1.0" +edition = "2024" + +[dependencies] +animation = { version = "0.1.0", path = "../animation" } +chrono = { version = "0.4", features = ["serde"] } +scene = { version = "0.1.0", path = "../scene" } +serde = { version = "1.0", features = ["derive"] } +serde_json = "1.0" +thiserror = "2" +time = { version = "0.1.0", path = "../time" } +timeline = { version = "0.1.0", path = "../timeline" } +uuid = { version = "1", features = ["v4"] } diff --git a/rust/crates/commands/src/batch_command.rs b/rust/crates/commands/src/batch_command.rs new file mode 100644 index 00000000..bd928d33 --- /dev/null +++ b/rust/crates/commands/src/batch_command.rs @@ -0,0 +1,61 @@ +use scene::Project; + +use crate::command::{Command, CommandError, CommandResult}; + +pub struct BatchCommand { + commands: Vec<Box<dyn Command>>, +} + +impl BatchCommand { + pub fn new(commands: Vec<Box<dyn Command>>) -> Self { + Self { commands } + } + + pub fn len(&self) -> usize { + self.commands.len() + } + + pub fn is_empty(&self) -> bool { + self.commands.is_empty() + } +} + +impl Command for BatchCommand { + fn execute( + &mut self, + project: &mut Project, + ) -> Result<Option<CommandResult>, CommandError> { + let mut latest_selection_result = None; + + for command in &mut self.commands { + let result = command.execute(project)?; + if result.as_ref().and_then(|r| r.selection.as_ref()).is_some() { + latest_selection_result = result; + } + } + + Ok(latest_selection_result) + } + + fn undo(&mut self, project: &mut Project) { + for command in self.commands.iter_mut().rev() { + command.undo(project); + } + } + + fn redo( + &mut self, + project: &mut Project, + ) -> Result<Option<CommandResult>, CommandError> { + let mut latest_selection_result = None; + + for command in &mut self.commands { + let result = command.redo(project)?; + if result.as_ref().and_then(|r| r.selection.as_ref()).is_some() { + latest_selection_result = result; + } + } + + Ok(latest_selection_result) + } +} diff --git a/rust/crates/commands/src/bookmarks.rs b/rust/crates/commands/src/bookmarks.rs new file mode 100644 index 00000000..79a73015 --- /dev/null +++ b/rust/crates/commands/src/bookmarks.rs @@ -0,0 +1,94 @@ +use scene::Bookmark; +use time::{FrameRate, MediaTime}; + +#[derive(Clone, Debug, Default, PartialEq)] +pub struct BookmarkPatch { + pub note: Option<Option<String>>, + pub color: Option<Option<String>>, + pub duration: Option<Option<MediaTime>>, +} + +pub fn find_bookmark_index(bookmarks: &[Bookmark], frame_time: MediaTime) -> Option<usize> { + bookmarks.iter().position(|bookmark| bookmark.time == frame_time) +} + +pub fn is_bookmark_at_time(bookmarks: &[Bookmark], frame_time: MediaTime) -> bool { + find_bookmark_index(bookmarks, frame_time).is_some() +} + +pub fn toggle_bookmark_in_array( + bookmarks: &[Bookmark], + frame_time: MediaTime, +) -> Vec<Bookmark> { + if let Some(index) = find_bookmark_index(bookmarks, frame_time) { + return bookmarks + .iter() + .enumerate() + .filter(|(bookmark_index, _)| *bookmark_index != index) + .map(|(_, bookmark)| bookmark.clone()) + .collect(); + } + + let mut next = bookmarks.to_vec(); + next.push(Bookmark { + time: frame_time, + note: None, + color: None, + duration: None, + }); + next.sort_by_key(|bookmark| bookmark.time); + next +} + +pub fn remove_bookmark_from_array( + bookmarks: &[Bookmark], + frame_time: MediaTime, +) -> Vec<Bookmark> { + bookmarks + .iter() + .filter(|bookmark| bookmark.time != frame_time) + .cloned() + .collect() +} + +pub fn update_bookmark_in_array( + bookmarks: &[Bookmark], + frame_time: MediaTime, + updates: &BookmarkPatch, +) -> Vec<Bookmark> { + let Some(index) = find_bookmark_index(bookmarks, frame_time) else { + return bookmarks.to_vec(); + }; + + let mut next = bookmarks.to_vec(); + let bookmark = &mut next[index]; + if let Some(note) = &updates.note { + bookmark.note = note.clone(); + } + if let Some(color) = &updates.color { + bookmark.color = color.clone(); + } + if let Some(duration) = &updates.duration { + bookmark.duration = *duration; + } + next +} + +pub fn move_bookmark_in_array( + bookmarks: &[Bookmark], + from_time: MediaTime, + to_time: MediaTime, +) -> Vec<Bookmark> { + let Some(index) = find_bookmark_index(bookmarks, from_time) else { + return bookmarks.to_vec(); + }; + + let mut next = bookmarks.to_vec(); + next[index].time = to_time; + next.sort_by_key(|bookmark| bookmark.time); + next +} + +pub fn get_frame_time(time: MediaTime, fps: FrameRate) -> MediaTime { + time.round_to_frame(fps).unwrap_or(time) +} diff --git a/rust/crates/commands/src/command.rs b/rust/crates/commands/src/command.rs new file mode 100644 index 00000000..c13468e7 --- /dev/null +++ b/rust/crates/commands/src/command.rs @@ -0,0 +1,46 @@ +use scene::{ElementRef, Project}; +use thiserror::Error; + +#[derive(Debug, Error)] +pub enum CommandError { + #[error("source track or element not found")] + SourceNotFound, + #[error("target track not found")] + TargetTrackNotFound, + #[error("{0}")] + IncompatiblePlacement(String), +} + +#[derive(Clone, Debug, Default, PartialEq)] +pub struct SelectionPatch { + pub selected_elements: Vec<ElementRef>, +} + +#[derive(Clone, Debug, Default, PartialEq)] +pub struct CommandResult { + pub selection: Option<SelectionPatch>, +} + +impl CommandResult { + pub fn element_selection(selected_elements: Vec<ElementRef>) -> Self { + Self { + selection: Some(SelectionPatch { selected_elements }), + } + } +} + +pub trait Command { + fn execute( + &mut self, + project: &mut Project, + ) -> Result<Option<CommandResult>, CommandError>; + + fn undo(&mut self, project: &mut Project); + + fn redo( + &mut self, + project: &mut Project, + ) -> Result<Option<CommandResult>, CommandError> { + self.execute(project) + } +} diff --git a/rust/crates/commands/src/history.rs b/rust/crates/commands/src/history.rs new file mode 100644 index 00000000..f8792373 --- /dev/null +++ b/rust/crates/commands/src/history.rs @@ -0,0 +1,68 @@ +use scene::Project; + +use crate::command::{Command, CommandError, CommandResult}; + +#[derive(Default)] +pub struct CommandHistory { + history: Vec<Box<dyn Command>>, + redo_stack: Vec<Box<dyn Command>>, +} + +impl CommandHistory { + pub fn new() -> Self { + Self::default() + } + + pub fn execute( + &mut self, + mut command: Box<dyn Command>, + project: &mut Project, + ) -> Result<Option<CommandResult>, CommandError> { + let result = command.execute(project)?; + self.history.push(command); + self.redo_stack.clear(); + Ok(result) + } + + pub fn push(&mut self, command: Box<dyn Command>) { + self.history.push(command); + self.redo_stack.clear(); + } + + pub fn undo(&mut self, project: &mut Project) { + let Some(mut command) = self.history.pop() else { + return; + }; + command.undo(project); + self.redo_stack.push(command); + } + + pub fn redo( + &mut self, + project: &mut Project, + ) -> Result<Option<CommandResult>, CommandError> { + let Some(mut command) = self.redo_stack.pop() else { + return Ok(None); + }; + let result = command.redo(project)?; + self.history.push(command); + Ok(result) + } + + pub fn can_undo(&self) -> bool { + !self.history.is_empty() + } + + pub fn can_redo(&self) -> bool { + !self.redo_stack.is_empty() + } + + pub fn len(&self) -> usize { + self.history.len() + } + + pub fn clear(&mut self) { + self.history.clear(); + self.redo_stack.clear(); + } +} diff --git a/rust/crates/commands/src/lib.rs b/rust/crates/commands/src/lib.rs new file mode 100644 index 00000000..3fede6bb --- /dev/null +++ b/rust/crates/commands/src/lib.rs @@ -0,0 +1,18 @@ +pub mod batch_command; +pub mod bookmarks; +pub mod command; +pub mod history; +pub mod preview_tracker; +pub mod project; +pub mod project_ext; +pub mod scene; +pub mod timeline; +pub mod tracks; + +pub use batch_command::BatchCommand; +pub use command::{Command, CommandError, CommandResult, SelectionPatch}; +pub use history::CommandHistory; +pub use preview_tracker::PreviewTracker; +pub use project::*; +pub use scene::*; +pub use timeline::*; diff --git a/rust/crates/commands/src/preview_tracker.rs b/rust/crates/commands/src/preview_tracker.rs new file mode 100644 index 00000000..4eb7484d --- /dev/null +++ b/rust/crates/commands/src/preview_tracker.rs @@ -0,0 +1,28 @@ +#[derive(Clone, Debug, Default)] +pub struct PreviewTracker<T> { + snapshot: Option<T>, +} + +impl<T: Clone> PreviewTracker<T> { + pub fn new() -> Self { + Self { snapshot: None } + } + + pub fn begin(&mut self, state: &T) { + if self.snapshot.is_none() { + self.snapshot = Some(state.clone()); + } + } + + pub fn is_active(&self) -> bool { + self.snapshot.is_some() + } + + pub fn snapshot(&self) -> Option<&T> { + self.snapshot.as_ref() + } + + pub fn end(&mut self) -> Option<T> { + self.snapshot.take() + } +} diff --git a/rust/crates/commands/src/project/mod.rs b/rust/crates/commands/src/project/mod.rs new file mode 100644 index 00000000..28e9c92c --- /dev/null +++ b/rust/crates/commands/src/project/mod.rs @@ -0,0 +1,3 @@ +pub mod update_project_settings; + +pub use update_project_settings::{ProjectSettingsPatch, UpdateProjectSettingsCommand}; diff --git a/rust/crates/commands/src/project/update_project_settings.rs b/rust/crates/commands/src/project/update_project_settings.rs new file mode 100644 index 00000000..a0f6e99c --- /dev/null +++ b/rust/crates/commands/src/project/update_project_settings.rs @@ -0,0 +1,77 @@ +use scene::{Background, CanvasSize, CanvasSizeMode, Project, ProjectSettings}; +use time::FrameRate; + +use crate::command::{Command, CommandError, CommandResult}; + +#[derive(Clone, Debug, Default, PartialEq)] +pub struct ProjectSettingsPatch { + pub fps: Option<FrameRate>, + pub canvas_size: Option<CanvasSize>, + pub canvas_size_mode: Option<Option<CanvasSizeMode>>, + pub last_custom_canvas_size: Option<Option<CanvasSize>>, + pub original_canvas_size: Option<Option<CanvasSize>>, + pub background: Option<Background>, +} + +impl ProjectSettingsPatch { + pub fn apply_to(&self, settings: &mut ProjectSettings) { + if let Some(fps) = self.fps { + settings.fps = fps; + } + if let Some(canvas_size) = self.canvas_size { + settings.canvas_size = canvas_size; + } + if let Some(canvas_size_mode) = &self.canvas_size_mode { + settings.canvas_size_mode = *canvas_size_mode; + } + if let Some(last_custom_canvas_size) = &self.last_custom_canvas_size { + settings.last_custom_canvas_size = *last_custom_canvas_size; + } + if let Some(original_canvas_size) = &self.original_canvas_size { + settings.original_canvas_size = *original_canvas_size; + } + if let Some(background) = &self.background { + settings.background = background.clone(); + } + } +} + +pub struct UpdateProjectSettingsCommand { + updates: ProjectSettingsPatch, + saved_settings: Option<ProjectSettings>, + saved_updated_at: Option<chrono::DateTime<chrono::Utc>>, +} + +impl UpdateProjectSettingsCommand { + pub fn new(updates: ProjectSettingsPatch) -> Self { + Self { + updates, + saved_settings: None, + saved_updated_at: None, + } + } +} + +impl Command for UpdateProjectSettingsCommand { + fn execute( + &mut self, + project: &mut Project, + ) -> Result<Option<CommandResult>, CommandError> { + self.saved_settings = Some(project.settings.clone()); + self.saved_updated_at = Some(project.metadata.updated_at); + + self.updates.apply_to(&mut project.settings); + project.metadata.updated_at = chrono::Utc::now(); + Ok(None) + } + + fn undo(&mut self, project: &mut Project) { + let (Some(saved_settings), Some(saved_updated_at)) = + (&self.saved_settings, self.saved_updated_at) + else { + return; + }; + project.settings = saved_settings.clone(); + project.metadata.updated_at = saved_updated_at; + } +} diff --git a/rust/crates/commands/src/project_ext.rs b/rust/crates/commands/src/project_ext.rs new file mode 100644 index 00000000..18262fbd --- /dev/null +++ b/rust/crates/commands/src/project_ext.rs @@ -0,0 +1,20 @@ +use scene::{Project, Scene}; + +pub fn active_scene(project: &Project) -> Option<&Scene> { + project + .scenes + .iter() + .find(|scene| scene.id == project.current_scene_id) + .or_else(|| project.scenes.iter().find(|scene| scene.is_main)) + .or_else(|| project.scenes.first()) +} + +pub fn active_scene_mut(project: &mut Project) -> Option<&mut Scene> { + let index = project + .scenes + .iter() + .position(|scene| scene.id == project.current_scene_id) + .or_else(|| project.scenes.iter().position(|scene| scene.is_main)) + .or(if project.scenes.is_empty() { None } else { Some(0) })?; + project.scenes.get_mut(index) +} diff --git a/rust/crates/commands/src/scene/create_scene.rs b/rust/crates/commands/src/scene/create_scene.rs new file mode 100644 index 00000000..1f2de02b --- /dev/null +++ b/rust/crates/commands/src/scene/create_scene.rs @@ -0,0 +1,46 @@ +use scene::{Project, Scene}; + +use crate::command::{Command, CommandError, CommandResult}; + +pub struct CreateSceneCommand { + name: String, + is_main: bool, + saved_scenes: Option<Vec<Scene>>, + created_scene_id: Option<String>, +} + +impl CreateSceneCommand { + pub fn new(name: impl Into<String>, is_main: bool) -> Self { + Self { + name: name.into(), + is_main, + saved_scenes: None, + created_scene_id: None, + } + } + + pub fn scene_id(&self) -> Option<&str> { + self.created_scene_id.as_deref() + } +} + +impl Command for CreateSceneCommand { + fn execute( + &mut self, + project: &mut Project, + ) -> Result<Option<CommandResult>, CommandError> { + self.saved_scenes = Some(project.scenes.clone()); + + let created = timeline::scenes::build_default_scene(&self.name, self.is_main); + self.created_scene_id = Some(created.id.clone()); + project.scenes.push(created); + Ok(None) + } + + fn undo(&mut self, project: &mut Project) { + let Some(saved) = &self.saved_scenes else { + return; + }; + project.scenes = saved.clone(); + } +} diff --git a/rust/crates/commands/src/scene/delete_scene.rs b/rust/crates/commands/src/scene/delete_scene.rs new file mode 100644 index 00000000..61763dfd --- /dev/null +++ b/rust/crates/commands/src/scene/delete_scene.rs @@ -0,0 +1,61 @@ +use scene::{Project, Scene}; + +use crate::command::{Command, CommandError, CommandResult}; + +pub struct DeleteSceneCommand { + scene_id: String, + saved_scenes: Option<Vec<Scene>>, + saved_current_scene_id: Option<String>, +} + +impl DeleteSceneCommand { + pub fn new(scene_id: impl Into<String>) -> Self { + Self { + scene_id: scene_id.into(), + saved_scenes: None, + saved_current_scene_id: None, + } + } +} + +impl Command for DeleteSceneCommand { + fn execute( + &mut self, + project: &mut Project, + ) -> Result<Option<CommandResult>, CommandError> { + let Some(deleted) = project.scenes.iter().find(|scene| scene.id == self.scene_id) + else { + return Ok(None); + }; + if deleted.is_main { + return Ok(None); + } + + self.saved_scenes = Some(project.scenes.clone()); + self.saved_current_scene_id = Some(project.current_scene_id.clone()); + + project.scenes.retain(|scene| scene.id != self.scene_id); + + if project.current_scene_id == self.scene_id { + let fallback_id = project + .scenes + .iter() + .find(|scene| scene.is_main) + .map(|scene| scene.id.clone()); + if let Some(fallback_id) = fallback_id { + project.current_scene_id = fallback_id; + } + } + Ok(None) + } + + fn undo(&mut self, project: &mut Project) { + let (Some(saved_scenes), Some(saved_current_scene_id)) = + (&self.saved_scenes, &self.saved_current_scene_id) + else { + return; + }; + project.scenes = saved_scenes.clone(); + project.current_scene_id = saved_current_scene_id.clone(); + } +} diff --git a/rust/crates/commands/src/scene/mod.rs b/rust/crates/commands/src/scene/mod.rs new file mode 100644 index 00000000..10d687c1 --- /dev/null +++ b/rust/crates/commands/src/scene/mod.rs @@ -0,0 +1,15 @@ +pub mod create_scene; +pub mod delete_scene; +pub mod move_bookmark; +pub mod remove_bookmark; +pub mod rename_scene; +pub mod toggle_bookmark; +pub mod update_bookmark; + +pub use create_scene::CreateSceneCommand; +pub use delete_scene::DeleteSceneCommand; +pub use move_bookmark::MoveBookmarkCommand; +pub use remove_bookmark::RemoveBookmarkCommand; +pub use rename_scene::RenameSceneCommand; +pub use toggle_bookmark::ToggleBookmarkCommand; +pub use update_bookmark::UpdateBookmarkCommand; diff --git a/rust/crates/commands/src/scene/move_bookmark.rs b/rust/crates/commands/src/scene/move_bookmark.rs new file mode 100644 index 00000000..dddef11f --- /dev/null +++ b/rust/crates/commands/src/scene/move_bookmark.rs @@ -0,0 +1,50 @@ +use scene::Project; +use time::MediaTime; + +use crate::bookmarks::{get_frame_time, move_bookmark_in_array}; +use crate::command::{Command, CommandError, CommandResult}; +use crate::project_ext::active_scene_mut; + +pub struct MoveBookmarkCommand { + from_time: MediaTime, + to_time: MediaTime, + saved_bookmarks: Option<Vec<scene::Bookmark>>, +} + +impl MoveBookmarkCommand { + pub fn new(from_time: MediaTime, to_time: MediaTime) -> Self { + Self { + from_time, + to_time, + saved_bookmarks: None, + } + } +} + +impl Command for MoveBookmarkCommand { + fn execute( + &mut self, + project: &mut Project, + ) -> Result<Option<CommandResult>, CommandError> { + let fps = project.settings.fps; + let Some(scene) = active_scene_mut(project) else { + return Ok(None); + }; + + self.saved_bookmarks = Some(scene.bookmarks.clone()); + let from_frame_time = get_frame_time(self.from_time, fps); + let to_frame_time = get_frame_time(self.to_time, fps); + scene.bookmarks = + move_bookmark_in_array(&scene.bookmarks, from_frame_time, to_frame_time); + Ok(None) + } + + fn undo(&mut self, project: &mut Project) { + let Some(saved) = &self.saved_bookmarks else { + return; + }; + if let Some(scene) = active_scene_mut(project) { + scene.bookmarks = saved.clone(); + } + } +} diff --git a/rust/crates/commands/src/scene/remove_bookmark.rs b/rust/crates/commands/src/scene/remove_bookmark.rs new file mode 100644 index 00000000..f8fe0c5a --- /dev/null +++ b/rust/crates/commands/src/scene/remove_bookmark.rs @@ -0,0 +1,54 @@ +use scene::Project; +use time::MediaTime; + +use crate::bookmarks::{get_frame_time, remove_bookmark_from_array}; +use crate::command::{Command, CommandError, CommandResult}; +use crate::project_ext::active_scene_mut; + +pub struct RemoveBookmarkCommand { + time: MediaTime, + frame_time: MediaTime, + saved_bookmarks: Option<Vec<scene::Bookmark>>, +} + +impl RemoveBookmarkCommand { + pub fn new(time: MediaTime) -> Self { + Self { + time, + frame_time: MediaTime::ZERO, + saved_bookmarks: None, + } + } +} + +impl Command for RemoveBookmarkCommand { + fn execute( + &mut self, + project: &mut Project, + ) -> Result<Option<CommandResult>, CommandError> { + let fps = project.settings.fps; + let Some(scene) = active_scene_mut(project) else { + return Ok(None); + }; + + self.saved_bookmarks = Some(scene.bookmarks.clone()); + self.frame_time = get_frame_time(self.time, fps); + + let updated = remove_bookmark_from_array(&scene.bookmarks, self.frame_time); + if updated.len() == scene.bookmarks.len() { + return Ok(None); + } + + scene.bookmarks = updated; + Ok(None) + } + + fn undo(&mut self, project: &mut Project) { + let Some(saved) = &self.saved_bookmarks else { + return; + }; + if let Some(scene) = active_scene_mut(project) { + scene.bookmarks = saved.clone(); + } + } +} diff --git a/rust/crates/commands/src/scene/rename_scene.rs b/rust/crates/commands/src/scene/rename_scene.rs new file mode 100644 index 00000000..4876fb9b --- /dev/null +++ b/rust/crates/commands/src/scene/rename_scene.rs @@ -0,0 +1,58 @@ +use scene::Project; + +use crate::command::{Command, CommandError, CommandResult}; + +pub struct RenameSceneCommand { + scene_id: String, + new_name: String, + saved_name: Option<String>, + saved_updated_at: Option<chrono::DateTime<chrono::Utc>>, +} + +impl RenameSceneCommand { + pub fn new(scene_id: impl Into<String>, new_name: impl Into<String>) -> Self { + Self { + scene_id: scene_id.into(), + new_name: new_name.into(), + saved_name: None, + saved_updated_at: None, + } + } +} + +impl Command for RenameSceneCommand { + fn execute( + &mut self, + project: &mut Project, + ) -> Result<Option<CommandResult>, CommandError> { + let Some(scene) = project + .scenes + .iter_mut() + .find(|scene| scene.id == self.scene_id) + else { + return Ok(None); + }; + + self.saved_name = Some(scene.name.clone()); + self.saved_updated_at = Some(scene.updated_at); + scene.name = self.new_name.clone(); + scene.updated_at = chrono::Utc::now(); + Ok(None) + } + + fn undo(&mut self, project: &mut Project) { + let (Some(saved_name), Some(saved_updated_at)) = + (&self.saved_name, self.saved_updated_at) + else { + return; + }; + if let Some(scene) = project + .scenes + .iter_mut() + .find(|scene| scene.id == self.scene_id) + { + scene.name = saved_name.clone(); + scene.updated_at = saved_updated_at; + } + } +} diff --git a/rust/crates/commands/src/scene/toggle_bookmark.rs b/rust/crates/commands/src/scene/toggle_bookmark.rs new file mode 100644 index 00000000..054e1c78 --- /dev/null +++ b/rust/crates/commands/src/scene/toggle_bookmark.rs @@ -0,0 +1,48 @@ +use scene::Project; + +use crate::bookmarks::{get_frame_time, toggle_bookmark_in_array}; +use crate::command::{Command, CommandError, CommandResult}; +use crate::project_ext::active_scene_mut; +use time::MediaTime; + +pub struct ToggleBookmarkCommand { + time: MediaTime, + frame_time: MediaTime, + saved_bookmarks: Option<Vec<scene::Bookmark>>, +} + +impl ToggleBookmarkCommand { + pub fn new(time: MediaTime) -> Self { + Self { + time, + frame_time: MediaTime::ZERO, + saved_bookmarks: None, + } + } +} + +impl Command for ToggleBookmarkCommand { + fn execute( + &mut self, + project: &mut Project, + ) -> Result<Option<CommandResult>, CommandError> { + let fps = project.settings.fps; + let Some(scene) = active_scene_mut(project) else { + return Ok(None); + }; + + self.saved_bookmarks = Some(scene.bookmarks.clone()); + self.frame_time = get_frame_time(self.time, fps); + scene.bookmarks = toggle_bookmark_in_array(&scene.bookmarks, self.frame_time); + Ok(None) + } + + fn undo(&mut self, project: &mut Project) { + let Some(saved) = &self.saved_bookmarks else { + return; + }; + if let Some(scene) = active_scene_mut(project) { + scene.bookmarks = saved.clone(); + } + } +} diff --git a/rust/crates/commands/src/scene/update_bookmark.rs b/rust/crates/commands/src/scene/update_bookmark.rs new file mode 100644 index 00000000..f2c9b94e --- /dev/null +++ b/rust/crates/commands/src/scene/update_bookmark.rs @@ -0,0 +1,49 @@ +use scene::Project; +use time::MediaTime; + +use crate::bookmarks::{BookmarkPatch, get_frame_time, update_bookmark_in_array}; +use crate::command::{Command, CommandError, CommandResult}; +use crate::project_ext::active_scene_mut; + +pub struct UpdateBookmarkCommand { + time: MediaTime, + updates: BookmarkPatch, + saved_bookmarks: Option<Vec<scene::Bookmark>>, +} + +impl UpdateBookmarkCommand { + pub fn new(time: MediaTime, updates: BookmarkPatch) -> Self { + Self { + time, + updates, + saved_bookmarks: None, + } + } +} + +impl Command for UpdateBookmarkCommand { + fn execute( + &mut self, + project: &mut Project, + ) -> Result<Option<CommandResult>, CommandError> { + let fps = project.settings.fps; + let Some(scene) = active_scene_mut(project) else { + return Ok(None); + }; + + self.saved_bookmarks = Some(scene.bookmarks.clone()); + let frame_time = get_frame_time(self.time, fps); + scene.bookmarks = + update_bookmark_in_array(&scene.bookmarks, frame_time, &self.updates); + Ok(None) + } + + fn undo(&mut self, project: &mut Project) { + let Some(saved) = &self.saved_bookmarks else { + return; + }; + if let Some(scene) = active_scene_mut(project) { + scene.bookmarks = saved.clone(); + } + } +} diff --git a/rust/crates/commands/src/timeline/element/delete_elements.rs b/rust/crates/commands/src/timeline/element/delete_elements.rs new file mode 100644 index 00000000..bdcb5b6d --- /dev/null +++ b/rust/crates/commands/src/timeline/element/delete_elements.rs @@ -0,0 +1,64 @@ +use scene::{ElementRef, Project, SceneTracks, Track}; + +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()) { + return; + } + track.elements_mut().retain(|element| { + !elements + .iter() + .any(|target| target.track_id == track.id() && target.element_id == element.base().id) + }); +} + +fn remove_elements_from_tracks(tracks: &mut SceneTracks, elements: &[ElementRef]) { + for track in &mut tracks.overlay { + remove_track_elements(track, elements); + } + remove_track_elements(&mut tracks.main, elements); + for track in &mut tracks.audio { + remove_track_elements(track, elements); + } +} + +pub struct DeleteElementsCommand { + elements: Vec<ElementRef>, + saved_state: Option<SceneTracks>, +} + +impl DeleteElementsCommand { + pub fn new(elements: Vec<ElementRef>) -> Self { + Self { + elements, + saved_state: None, + } + } +} + +impl Command for DeleteElementsCommand { + 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()); + remove_elements_from_tracks(&mut scene.tracks, &self.elements); + + Ok(Some(CommandResult::element_selection(Vec::new()))) + } + + 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 new file mode 100644 index 00000000..2ba806e1 --- /dev/null +++ b/rust/crates/commands/src/timeline/element/mod.rs @@ -0,0 +1,3 @@ +pub mod delete_elements; + +pub use delete_elements::DeleteElementsCommand; diff --git a/rust/crates/commands/src/timeline/element/move_elements.rs b/rust/crates/commands/src/timeline/element/move_elements.rs new file mode 100644 index 00000000..fc6425d1 --- /dev/null +++ b/rust/crates/commands/src/timeline/element/move_elements.rs @@ -0,0 +1,145 @@ +use scene::{Element, Project, SceneTracks, Track}; +use time::MediaTime; +use timeline::TrackType; +use timeline::placement::build_empty_track; + +use crate::command::{Command, CommandError, CommandResult}; +use crate::project_ext::active_scene_mut; +use crate::tracks::{all_tracks_mut, find_track}; + +#[derive(Clone, Debug, PartialEq)] +pub struct PlannedTrackCreation { + pub id: String, + pub track_type: TrackType, + pub index: usize, +} + +#[derive(Clone, Debug, PartialEq)] +pub struct PlannedElementMove { + pub source_track_id: String, + pub target_track_id: String, + pub element_id: String, + pub new_start_time: MediaTime, +} + +fn insert_track_at_display_index( + tracks: &mut SceneTracks, + track: Track, + insert_index: usize, +) { + if matches!(track, Track::Audio { .. }) { + let audio_insert_index = insert_index + .saturating_sub(tracks.overlay.len() + 1) + .min(tracks.audio.len()); + tracks.audio.insert(audio_insert_index, track); + return; + } + + let overlay_insert_index = insert_index.min(tracks.overlay.len()); + tracks.overlay.insert(overlay_insert_index, track); +} + +fn validate_compatibility(element: &Element, track: &Track) -> Result<(), CommandError> { + timeline::placement::validate_element_track_compatibility(element, track) + .map_err(CommandError::IncompatiblePlacement) +} + +pub struct MoveElementCommand { + moves: Vec<PlannedElementMove>, + create_tracks: Vec<PlannedTrackCreation>, + saved_state: Option<SceneTracks>, +} + +impl MoveElementCommand { + pub fn new(moves: Vec<PlannedElementMove>, create_tracks: Vec<PlannedTrackCreation>) -> Self { + Self { + moves, + create_tracks, + saved_state: None, + } + } +} + +impl Command for MoveElementCommand { + fn execute( + &mut self, + project: &mut Project, + ) -> Result<Option<CommandResult>, CommandError> { + let Some(scene) = active_scene_mut(project) else { + return Ok(None); + }; + + let saved_state = scene.tracks.clone(); + self.saved_state = Some(saved_state.clone()); + + 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); + insert_track_at_display_index(&mut scene.tracks, track, creation.index); + } + + let mut moved_elements = Vec::new(); + for planned_move in &self.moves { + let source_track = find_track(&saved_state, &planned_move.source_track_id); + let source_element = source_track.and_then(|track| { + track + .elements() + .iter() + .find(|element| element.base().id == planned_move.element_id) + }); + let Some(source_element) = source_element else { + return Err(CommandError::SourceNotFound); + }; + + let Some(target_track) = find_track(&scene.tracks, &planned_move.target_track_id) + else { + return Err(CommandError::TargetTrackNotFound); + }; + + validate_compatibility(source_element, target_track)?; + + let mut moved = source_element.clone(); + moved.base_mut().start_time = planned_move.new_start_time; + moved_elements.push((planned_move.target_track_id.clone(), moved)); + } + + let moved_ids: Vec<&str> = self + .moves + .iter() + .map(|planned_move| planned_move.element_id.as_str()) + .collect(); + + for track in all_tracks_mut(&mut scene.tracks) { + let track_id = track.id().to_string(); + let mut elements = std::mem::take(track.elements_mut()); + elements.retain(|element| !moved_ids.contains(&element.base().id.as_str())); + elements.extend( + moved_elements + .iter() + .filter(|(target_track_id, _)| *target_track_id == track_id) + .map(|(_, element)| element.clone()), + ); + *track.elements_mut() = elements; + } + + Ok(Some(CommandResult::element_selection( + self.moves + .iter() + .map(|planned_move| scene::ElementRef { + track_id: planned_move.target_track_id.clone(), + element_id: planned_move.element_id.clone(), + }) + .collect(), + ))) + } + + 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/shift_split_remainder.rs b/rust/crates/commands/src/timeline/element/shift_split_remainder.rs new file mode 100644 index 00000000..be2f78e4 --- /dev/null +++ b/rust/crates/commands/src/timeline/element/shift_split_remainder.rs @@ -0,0 +1,58 @@ +use std::cell::RefCell; +use std::rc::Rc; + +use scene::{ElementRef, Project}; +use time::MediaTime; + +use crate::command::{Command, CommandError, CommandResult}; +use crate::timeline::element::move_elements::{MoveElementCommand, PlannedElementMove}; + +pub struct ShiftSplitRemainderCommand { + right_side: Rc<RefCell<Vec<ElementRef>>>, + new_start_time: MediaTime, + inner: Option<MoveElementCommand>, +} + +impl ShiftSplitRemainderCommand { + pub fn new(right_side: Rc<RefCell<Vec<ElementRef>>>, new_start_time: MediaTime) -> Self { + Self { + right_side, + new_start_time, + inner: None, + } + } +} + +impl Command for ShiftSplitRemainderCommand { + fn execute( + &mut self, + project: &mut Project, + ) -> Result<Option<CommandResult>, CommandError> { + let right_side = self.right_side.borrow().clone(); + if right_side.is_empty() { + return Ok(None); + } + + let mut inner = MoveElementCommand::new( + right_side + .iter() + .map(|element| PlannedElementMove { + source_track_id: element.track_id.clone(), + target_track_id: element.track_id.clone(), + element_id: element.element_id.clone(), + new_start_time: self.new_start_time, + }) + .collect(), + Vec::new(), + ); + let result = inner.execute(project)?; + self.inner = Some(inner); + Ok(result) + } + + fn undo(&mut self, project: &mut Project) { + if let Some(inner) = &mut self.inner { + inner.undo(project); + } + } +} diff --git a/rust/crates/commands/src/timeline/element/split_elements.rs b/rust/crates/commands/src/timeline/element/split_elements.rs new file mode 100644 index 00000000..0da90792 --- /dev/null +++ b/rust/crates/commands/src/timeline/element/split_elements.rs @@ -0,0 +1,225 @@ +use std::cell::RefCell; +use std::rc::Rc; + +use scene::{AudioElement, Element, ElementRef, Project, RetimeConfig, SceneTracks}; +use time::MediaTime; +use uuid::Uuid; + +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>, + split_time: MediaTime, + retain_side: RetainSide, + saved_state: Option<SceneTracks>, + right_side: Rc<RefCell<Vec<ElementRef>>>, +} + +impl SplitElementsCommand { + pub fn new(elements: Vec<ElementRef>, split_time: MediaTime, retain_side: RetainSide) -> Self { + Self { + elements, + split_time, + retain_side, + saved_state: None, + right_side: Rc::new(RefCell::new(Vec::new())), + } + } + + pub fn right_side_handle(&self) -> Rc<RefCell<Vec<ElementRef>>> { + Rc::clone(&self.right_side) + } + + 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 { + 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.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 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; + } + + let right_side = self.right_side.borrow(); + if right_side.is_empty() { + return Ok(None); + } + Ok(Some(CommandResult::element_selection(right_side.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/update_elements.rs b/rust/crates/commands/src/timeline/element/update_elements.rs new file mode 100644 index 00000000..f5931bb7 --- /dev/null +++ b/rust/crates/commands/src/timeline/element/update_elements.rs @@ -0,0 +1,72 @@ +use scene::{ElementRef, 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)] +pub struct ElementUpdate { + pub track_id: String, + pub element_id: String, + pub patch: ElementPatch, +} + +pub struct UpdateElementsCommand { + updates: Vec<ElementUpdate>, + saved_state: Option<SceneTracks>, +} + +impl UpdateElementsCommand { + pub fn new(updates: Vec<ElementUpdate>) -> Self { + Self { + updates, + saved_state: None, + } + } +} + +impl Command for UpdateElementsCommand { + 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()); + + for update in &self.updates { + let context_tracks = scene.tracks.clone(); + let context = ElementUpdateContext { + tracks: &context_tracks, + track_id: &update.track_id, + }; + let Some(track) = find_track_mut(&mut scene.tracks, &update.track_id) else { + continue; + }; + let Some(element) = track + .elements_mut() + .iter_mut() + .find(|element| element.base().id == update.element_id) + else { + continue; + }; + + let current = element.clone(); + *element = apply_element_update(current, update.patch.clone(), &context); + } + + Ok(None) + } + + 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/mod.rs b/rust/crates/commands/src/timeline/mod.rs new file mode 100644 index 00000000..532885ce --- /dev/null +++ b/rust/crates/commands/src/timeline/mod.rs @@ -0,0 +1,5 @@ +pub mod element; +pub mod track; +pub mod tracks_snapshot; + +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 new file mode 100644 index 00000000..d6e9bbae --- /dev/null +++ b/rust/crates/commands/src/timeline/track/add_track.rs @@ -0,0 +1,69 @@ +use scene::{Project, SceneTracks}; +use timeline::placement::{build_empty_track, get_default_insert_index_for_track}; +use timeline::TrackType; +use uuid::Uuid; + +use crate::command::{Command, CommandError, CommandResult}; +use crate::project_ext::active_scene_mut; + +pub struct AddTrackCommand { + track_type: TrackType, + index: Option<usize>, + track_id: String, + saved_state: Option<SceneTracks>, +} + +impl AddTrackCommand { + pub fn new(track_type: TrackType, index: Option<usize>) -> Self { + Self { + track_type, + index, + track_id: Uuid::new_v4().to_string(), + saved_state: None, + } + } + + pub fn track_id(&self) -> &str { + &self.track_id + } +} + +impl Command for AddTrackCommand { + 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()); + + let insert_index = self.index.unwrap_or_else(|| { + get_default_insert_index_for_track(&scene.tracks, self.track_type) + }); + + let new_track = build_empty_track(&self.track_id, self.track_type, None); + if self.track_type == TrackType::Audio { + let audio_insert_index = insert_index + .saturating_sub(scene.tracks.overlay.len() + 1); + scene + .tracks + .audio + .insert(audio_insert_index.min(scene.tracks.audio.len()), new_track); + } else { + let overlay_insert_index = insert_index.min(scene.tracks.overlay.len()); + scene.tracks.overlay.insert(overlay_insert_index, new_track); + } + Ok(None) + } + + 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/track/mod.rs b/rust/crates/commands/src/timeline/track/mod.rs new file mode 100644 index 00000000..c133a12d --- /dev/null +++ b/rust/crates/commands/src/timeline/track/mod.rs @@ -0,0 +1,9 @@ +pub mod add_track; +pub mod remove_track; +pub mod toggle_track_mute; +pub mod toggle_track_visibility; + +pub use add_track::AddTrackCommand; +pub use remove_track::RemoveTrackCommand; +pub use toggle_track_mute::ToggleTrackMuteCommand; +pub use toggle_track_visibility::ToggleTrackVisibilityCommand; diff --git a/rust/crates/commands/src/timeline/track/remove_track.rs b/rust/crates/commands/src/timeline/track/remove_track.rs new file mode 100644 index 00000000..5a79b4ae --- /dev/null +++ b/rust/crates/commands/src/timeline/track/remove_track.rs @@ -0,0 +1,43 @@ +use scene::{Project, SceneTracks}; + +use crate::command::{Command, CommandError, CommandResult}; +use crate::project_ext::active_scene_mut; + +pub struct RemoveTrackCommand { + track_id: String, + saved_state: Option<SceneTracks>, +} + +impl RemoveTrackCommand { + pub fn new(track_id: impl Into<String>) -> Self { + Self { + track_id: track_id.into(), + saved_state: None, + } + } +} + +impl Command for RemoveTrackCommand { + 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()); + scene.tracks.overlay.retain(|track| track.id() != self.track_id); + scene.tracks.audio.retain(|track| track.id() != self.track_id); + Ok(None) + } + + 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/track/toggle_track_mute.rs b/rust/crates/commands/src/timeline/track/toggle_track_mute.rs new file mode 100644 index 00000000..afaaf3f3 --- /dev/null +++ b/rust/crates/commands/src/timeline/track/toggle_track_mute.rs @@ -0,0 +1,49 @@ +use scene::{Project, SceneTracks, Track}; + +use crate::command::{Command, CommandError, CommandResult}; +use crate::project_ext::active_scene_mut; +use crate::tracks::find_track_mut; + +pub struct ToggleTrackMuteCommand { + track_id: String, + saved_state: Option<SceneTracks>, +} + +impl ToggleTrackMuteCommand { + pub fn new(track_id: impl Into<String>) -> Self { + Self { + track_id: track_id.into(), + saved_state: None, + } + } +} + +impl Command for ToggleTrackMuteCommand { + 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()); + + match find_track_mut(&mut scene.tracks, &self.track_id) { + Some(Track::Video { muted, .. }) | Some(Track::Audio { muted, .. }) => { + *muted = !*muted; + } + _ => {} + } + Ok(None) + } + + 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/track/toggle_track_visibility.rs b/rust/crates/commands/src/timeline/track/toggle_track_visibility.rs new file mode 100644 index 00000000..79000a73 --- /dev/null +++ b/rust/crates/commands/src/timeline/track/toggle_track_visibility.rs @@ -0,0 +1,54 @@ +use scene::{Project, SceneTracks, Track}; + +use crate::command::{Command, CommandError, CommandResult}; +use crate::project_ext::active_scene_mut; +use crate::tracks::find_track_mut; + +pub struct ToggleTrackVisibilityCommand { + track_id: String, + saved_state: Option<SceneTracks>, +} + +impl ToggleTrackVisibilityCommand { + pub fn new(track_id: impl Into<String>) -> Self { + Self { + track_id: track_id.into(), + saved_state: None, + } + } +} + +impl Command for ToggleTrackVisibilityCommand { + 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()); + + match find_track_mut(&mut scene.tracks, &self.track_id) { + Some( + Track::Video { hidden, .. } + | Track::Text { hidden, .. } + | Track::Graphic { hidden, .. } + | Track::Effect { hidden, .. }, + ) => { + *hidden = !*hidden; + } + _ => {} + } + Ok(None) + } + + 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/tracks_snapshot.rs b/rust/crates/commands/src/timeline/tracks_snapshot.rs new file mode 100644 index 00000000..5108e123 --- /dev/null +++ b/rust/crates/commands/src/timeline/tracks_snapshot.rs @@ -0,0 +1,33 @@ +use scene::{Project, SceneTracks}; + +use crate::command::{Command, CommandError, CommandResult}; +use crate::project_ext::active_scene_mut; + +pub struct TracksSnapshotCommand { + before: SceneTracks, + after: SceneTracks, +} + +impl TracksSnapshotCommand { + pub fn new(before: SceneTracks, after: SceneTracks) -> Self { + Self { before, after } + } +} + +impl Command for TracksSnapshotCommand { + fn execute( + &mut self, + project: &mut Project, + ) -> Result<Option<CommandResult>, CommandError> { + if let Some(scene) = active_scene_mut(project) { + scene.tracks = self.after.clone(); + } + Ok(None) + } + + fn undo(&mut self, project: &mut Project) { + if let Some(scene) = active_scene_mut(project) { + scene.tracks = self.before.clone(); + } + } +} diff --git a/rust/crates/commands/src/tracks.rs b/rust/crates/commands/src/tracks.rs new file mode 100644 index 00000000..332a5e6b --- /dev/null +++ b/rust/crates/commands/src/tracks.rs @@ -0,0 +1,42 @@ +use scene::{SceneTracks, Track}; + +pub fn find_track<'a>(tracks: &'a SceneTracks, track_id: &str) -> Option<&'a Track> { + if tracks.main.id() == track_id { + return Some(&tracks.main); + } + tracks + .overlay + .iter() + .find(|track| track.id() == track_id) + .or_else(|| tracks.audio.iter().find(|track| track.id() == track_id)) +} + +pub fn find_track_mut<'a>( + tracks: &'a mut SceneTracks, + track_id: &str, +) -> Option<&'a mut Track> { + if tracks.main.id() == track_id { + return Some(&mut tracks.main); + } + tracks + .overlay + .iter_mut() + .find(|track| track.id() == track_id) + .or_else(|| tracks.audio.iter_mut().find(|track| track.id() == track_id)) +} + +pub fn all_tracks(tracks: &SceneTracks) -> impl Iterator<Item = &Track> { + tracks + .overlay + .iter() + .chain(std::iter::once(&tracks.main)) + .chain(tracks.audio.iter()) +} + +pub fn all_tracks_mut(tracks: &mut SceneTracks) -> impl Iterator<Item = &mut Track> { + tracks + .overlay + .iter_mut() + .chain(std::iter::once(&mut tracks.main)) + .chain(tracks.audio.iter_mut()) +} diff --git a/rust/crates/compositor/tests/offscreen_render.rs b/rust/crates/compositor/tests/offscreen_render.rs new file mode 100755 index 00000000..a3c6df10 --- /dev/null +++ b/rust/crates/compositor/tests/offscreen_render.rs @@ -0,0 +1,96 @@ +#![cfg(not(target_arch = "wasm32"))] + +use compositor::{ + CanvasClearDescriptor, Compositor, FrameDescriptor, FrameItemDescriptor, LayerDescriptor, + QuadTransformDescriptor, +}; +use gpu::GpuContext; + +fn block_on<F: Future>(future: F) -> F::Output { + use std::sync::Arc; + use std::task::{Context, Poll, Wake, Waker}; + + struct ThreadWaker(std::thread::Thread); + + impl Wake for ThreadWaker { + fn wake(self: Arc<Self>) { + self.0.unpark(); + } + } + + let waker = Waker::from(Arc::new(ThreadWaker(std::thread::current()))); + let mut context = Context::from_waker(&waker); + let mut future = Box::pin(future); + loop { + match future.as_mut().poll(&mut context) { + Poll::Ready(output) => return output, + Poll::Pending => std::thread::park(), + } + } +} + +#[test] +fn renders_solid_layer_offscreen() { + let Some(context) = block_on(GpuContext::new()).ok() else { + eprintln!("skipping: no GPU adapter available"); + return; + }; + let mut compositor = Compositor::new(&context); + + let width = 64; + let height = 64; + let red = [255u8, 0, 0, 255]; + let pixels: Vec<u8> = red.repeat((width * height) as usize); + let texture = context.create_render_texture(width, height, "test-source"); + context.queue().write_texture( + texture.as_image_copy(), + &pixels, + gpu::wgpu::TexelCopyBufferLayout { + offset: 0, + bytes_per_row: Some(width * 4), + rows_per_image: Some(height), + }, + texture.size(), + ); + compositor.upsert_texture("source".to_string(), texture); + + let frame = FrameDescriptor { + width, + height, + clear: CanvasClearDescriptor { + color: [0.0, 0.0, 0.0, 1.0], + }, + items: vec![FrameItemDescriptor::Layer(LayerDescriptor { + texture_id: "source".to_string(), + transform: QuadTransformDescriptor { + center_x: 32.0, + center_y: 32.0, + width: width as f32, + height: height as f32, + rotation_degrees: 0.0, + flip_x: false, + flip_y: false, + }, + opacity: 1.0, + blend_mode: compositor::BlendMode::Normal, + effect_pass_groups: Vec::new(), + mask: None, + })], + }; + + let output = compositor + .render_frame_to_texture(&context, &frame) + .expect("render"); + let rendered = block_on(context.read_texture(&output)).expect("readback"); + assert_eq!(rendered.len(), (width * height * 4) as usize); + + let red_count = rendered + .chunks_exact(4) + .filter(|px| px[0] > 200 && px[1] < 60 && px[2] < 60) + .count(); + let total = (width * height) as usize; + assert!( + red_count * 100 / total > 90, + "expected mostly red frame, got {red_count}/{total} red pixels" + ); +} diff --git a/rust/crates/effect-defs/Cargo.toml b/rust/crates/effect-defs/Cargo.toml new file mode 100644 index 00000000..26872649 --- /dev/null +++ b/rust/crates/effect-defs/Cargo.toml @@ -0,0 +1,9 @@ +[package] +name = "effect-defs" +version = "0.1.0" +edition = "2024" + +[dependencies] +scene = { version = "0.1.0", path = "../scene" } +effects = { version = "0.1.0", path = "../effects" } +thiserror = "2" diff --git a/rust/crates/effect-defs/src/lib.rs b/rust/crates/effect-defs/src/lib.rs new file mode 100644 index 00000000..e326a1a9 --- /dev/null +++ b/rust/crates/effect-defs/src/lib.rs @@ -0,0 +1,64 @@ +mod definitions; +mod math; +mod params; +mod registry; +mod types; + +use effects::EffectPass; +use scene::ParamValues; +use thiserror::Error; + +pub use definitions::blur::{ + GAUSSIAN_BLUR_SHADER, INTENSITY_TO_SIGMA_DIVISOR, blur_effect_definition, + build_gaussian_blur_passes, intensity_to_sigma, parse_intensity, +}; +pub use definitions::register_default_effects; +pub use params::{ + BooleanParamDefinition, ColorParamDefinition, FontParamDefinition, NumberParamDefinition, + ParamDefinition, ParamDependency, SelectOption, SelectParamDefinition, TextParamDefinition, + build_default_param_values, +}; +pub use registry::EffectsRegistry; +pub use types::{EffectDefinition, EffectPassTemplate, EffectRendererConfig, PassContext}; + +#[derive(Debug, Error, PartialEq)] +pub enum EffectDefsError { + #[error("Unknown effect: {effect_type}")] + UnknownEffect { effect_type: String }, +} + +pub fn resolve_effect_passes( + definition: &EffectDefinition, + effect_params: &ParamValues, + width: u32, + height: u32, +) -> Vec<EffectPass> { + let context = PassContext { + effect_params, + width, + height, + }; + if let Some(build_passes) = definition.renderer.build_passes { + return build_passes(&context); + } + definition + .renderer + .passes + .iter() + .map(|pass| EffectPass { + shader: pass.shader.to_string(), + uniforms: (pass.uniforms)(&context), + }) + .collect() +} + +pub fn params_with_defaults( + definition: &EffectDefinition, + effect_params: &ParamValues, +) -> ParamValues { + let mut values = build_default_param_values(&definition.params); + for (key, value) in effect_params { + values.insert(key.clone(), value.clone()); + } + values +} diff --git a/rust/crates/effect-defs/src/math.rs b/rust/crates/effect-defs/src/math.rs new file mode 100644 index 00000000..dafc281e --- /dev/null +++ b/rust/crates/effect-defs/src/math.rs @@ -0,0 +1,57 @@ +pub(crate) fn js_max(a: f64, b: f64) -> f64 { + if a.is_nan() || b.is_nan() { f64::NAN } else { a.max(b) } +} + +pub(crate) fn js_min(a: f64, b: f64) -> f64 { + if a.is_nan() || b.is_nan() { f64::NAN } else { a.min(b) } +} + +pub(crate) fn js_parse_float(value: &str) -> f64 { + let trimmed = value.trim_start(); + let (sign, rest) = match trimmed.strip_prefix('-') { + Some(rest) => (-1.0, rest), + None => (1.0, trimmed.strip_prefix('+').unwrap_or(trimmed)), + }; + if rest.starts_with("Infinity") { + return sign * f64::INFINITY; + } + let bytes = rest.as_bytes(); + let mut end = 0; + let mut seen_digit = false; + let mut seen_dot = false; + while end < bytes.len() { + match bytes[end] { + b'0'..=b'9' => { + seen_digit = true; + end += 1; + } + b'.' if !seen_dot => { + seen_dot = true; + end += 1; + } + _ => break, + } + } + if !seen_digit { + return f64::NAN; + } + let mantissa_end = end; + if end < bytes.len() && (bytes[end] == b'e' || bytes[end] == b'E') { + let mut exponent_end = end + 1; + if exponent_end < bytes.len() + && (bytes[exponent_end] == b'+' || bytes[exponent_end] == b'-') + { + exponent_end += 1; + } + let digits_start = exponent_end; + while exponent_end < bytes.len() && bytes[exponent_end].is_ascii_digit() { + exponent_end += 1; + } + if exponent_end > digits_start { + end = exponent_end; + } else { + end = mantissa_end; + } + } + sign * rest[..end].parse::<f64>().unwrap_or(f64::NAN) +} diff --git a/rust/crates/effect-defs/src/params.rs b/rust/crates/effect-defs/src/params.rs new file mode 100644 index 00000000..cc7c43dc --- /dev/null +++ b/rust/crates/effect-defs/src/params.rs @@ -0,0 +1,165 @@ +use scene::params::{ParamValue, ParamValues}; + +#[derive(Clone, Debug, PartialEq)] +pub struct ParamDependency { + pub param: String, + pub equals: ParamValue, +} + +#[derive(Clone, Debug, PartialEq)] +pub struct SelectOption { + pub value: String, + pub label: String, +} + +#[derive(Clone, Debug, PartialEq)] +pub struct NumberParamDefinition { + pub key: String, + pub label: String, + pub default_value: f64, + pub min: f64, + pub max: Option<f64>, + pub step: f64, + pub group: Option<String>, + pub keyframable: Option<bool>, + pub dependencies: Vec<ParamDependency>, + pub display_multiplier: Option<f64>, + pub unit: Option<String>, + pub short_label: Option<String>, +} + +impl NumberParamDefinition { + pub fn new( + key: &str, + label: &str, + default_value: f64, + min: f64, + max: Option<f64>, + step: f64, + ) -> Self { + Self { + key: key.to_string(), + label: label.to_string(), + default_value, + min, + max, + step, + group: None, + keyframable: None, + dependencies: Vec::new(), + display_multiplier: None, + unit: None, + short_label: None, + } + } +} + +#[derive(Clone, Debug, PartialEq)] +pub struct BooleanParamDefinition { + pub key: String, + pub label: String, + pub default_value: bool, + pub group: Option<String>, + pub keyframable: Option<bool>, + pub dependencies: Vec<ParamDependency>, +} + +#[derive(Clone, Debug, PartialEq)] +pub struct ColorParamDefinition { + pub key: String, + pub label: String, + pub default_value: String, + pub group: Option<String>, + pub keyframable: Option<bool>, + pub dependencies: Vec<ParamDependency>, +} + +#[derive(Clone, Debug, PartialEq)] +pub struct SelectParamDefinition { + pub key: String, + pub label: String, + pub default_value: String, + pub options: Vec<SelectOption>, + pub group: Option<String>, + pub keyframable: Option<bool>, + pub dependencies: Vec<ParamDependency>, +} + +#[derive(Clone, Debug, PartialEq)] +pub struct TextParamDefinition { + pub key: String, + pub label: String, + pub default_value: String, + pub group: Option<String>, + pub keyframable: Option<bool>, + pub dependencies: Vec<ParamDependency>, +} + +#[derive(Clone, Debug, PartialEq)] +pub struct FontParamDefinition { + pub key: String, + pub label: String, + pub default_value: String, + pub group: Option<String>, + pub keyframable: Option<bool>, + pub dependencies: Vec<ParamDependency>, +} + +#[derive(Clone, Debug, PartialEq)] +pub enum ParamDefinition { + Number(NumberParamDefinition), + Boolean(BooleanParamDefinition), + Color(ColorParamDefinition), + Select(SelectParamDefinition), + Text(TextParamDefinition), + Font(FontParamDefinition), +} + +impl ParamDefinition { + pub fn key(&self) -> &str { + match self { + Self::Number(param) => ¶m.key, + Self::Boolean(param) => ¶m.key, + Self::Color(param) => ¶m.key, + Self::Select(param) => ¶m.key, + Self::Text(param) => ¶m.key, + Self::Font(param) => ¶m.key, + } + } + + pub fn label(&self) -> &str { + match self { + Self::Number(param) => ¶m.label, + Self::Boolean(param) => ¶m.label, + Self::Color(param) => ¶m.label, + Self::Select(param) => ¶m.label, + Self::Text(param) => ¶m.label, + Self::Font(param) => ¶m.label, + } + } + + pub fn default_value(&self) -> ParamValue { + match self { + Self::Number(param) => ParamValue::Number(param.default_value), + Self::Boolean(param) => ParamValue::Bool(param.default_value), + Self::Color(param) => ParamValue::String(param.default_value.clone()), + Self::Select(param) => ParamValue::String(param.default_value.clone()), + Self::Text(param) => ParamValue::String(param.default_value.clone()), + Self::Font(param) => ParamValue::String(param.default_value.clone()), + } + } + + pub fn numeric_range(&self) -> Option<(f64, Option<f64>, f64)> { + match self { + Self::Number(param) => Some((param.min, param.max, param.step)), + _ => None, + } + } +} + +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/effect-defs/src/registry.rs b/rust/crates/effect-defs/src/registry.rs new file mode 100644 index 00000000..340f9b9b --- /dev/null +++ b/rust/crates/effect-defs/src/registry.rs @@ -0,0 +1,35 @@ +use std::collections::HashMap; + +use crate::{EffectDefsError, types::EffectDefinition}; + +#[derive(Default)] +pub struct EffectsRegistry { + definitions: HashMap<String, EffectDefinition>, +} + +impl EffectsRegistry { + pub fn new() -> Self { + Self::default() + } + + pub fn register(&mut self, definition: EffectDefinition) { + self.definitions + .insert(definition.effect_type.to_string(), definition); + } + + pub fn has(&self, key: &str) -> bool { + self.definitions.contains_key(key) + } + + pub fn get(&self, key: &str) -> Result<&EffectDefinition, EffectDefsError> { + self.definitions + .get(key) + .ok_or_else(|| EffectDefsError::UnknownEffect { + effect_type: key.to_string(), + }) + } + + pub fn get_all(&self) -> Vec<&EffectDefinition> { + self.definitions.values().collect() + } +} diff --git a/rust/crates/effect-defs/src/types.rs b/rust/crates/effect-defs/src/types.rs new file mode 100644 index 00000000..c9815fd7 --- /dev/null +++ b/rust/crates/effect-defs/src/types.rs @@ -0,0 +1,30 @@ +use std::collections::HashMap; + +use effects::{EffectPass, UniformValue}; +use scene::params::ParamValues; + +use crate::params::ParamDefinition; + +pub struct PassContext<'a> { + pub effect_params: &'a ParamValues, + pub width: u32, + pub height: u32, +} + +pub struct EffectPassTemplate { + pub shader: &'static str, + pub uniforms: fn(&PassContext) -> HashMap<String, UniformValue>, +} + +pub struct EffectRendererConfig { + pub passes: Vec<EffectPassTemplate>, + pub build_passes: Option<fn(&PassContext) -> Vec<EffectPass>>, +} + +pub struct EffectDefinition { + pub effect_type: &'static str, + pub name: &'static str, + pub keywords: &'static [&'static str], + pub params: Vec<ParamDefinition>, + pub renderer: EffectRendererConfig, +} diff --git a/rust/crates/gpu/src/context.rs b/rust/crates/gpu/src/context.rs index da835280..04d7d148 100644 --- a/rust/crates/gpu/src/context.rs +++ b/rust/crates/gpu/src/context.rs @@ -317,6 +317,68 @@ impl GpuContext { }) } + pub async fn read_texture( + &self, + texture: &wgpu::Texture, + ) -> Result<Vec<u8>, GpuError> { + let size = texture.size(); + let unpadded_bytes_per_row = size.width * 4; + let align = wgpu::COPY_BYTES_PER_ROW_ALIGNMENT; + let padded_bytes_per_row = unpadded_bytes_per_row.div_ceil(align) * align; + let buffer = self.device.create_buffer(&wgpu::BufferDescriptor { + label: Some("gpu-readback-buffer"), + size: (padded_bytes_per_row * size.height) as u64, + usage: wgpu::BufferUsages::MAP_READ | wgpu::BufferUsages::COPY_DST, + mapped_at_creation: false, + }); + let mut encoder = self + .device + .create_command_encoder(&wgpu::CommandEncoderDescriptor { + label: Some("gpu-readback-encoder"), + }); + encoder.copy_texture_to_buffer( + texture.as_image_copy(), + wgpu::TexelCopyBufferInfo { + buffer: &buffer, + layout: wgpu::TexelCopyBufferLayout { + offset: 0, + bytes_per_row: Some(padded_bytes_per_row), + rows_per_image: Some(size.height), + }, + }, + size, + ); + self.queue.submit([encoder.finish()]); + + let slice = buffer.slice(..); + let (sender, receiver) = std::sync::mpsc::channel(); + slice.map_async(wgpu::MapMode::Read, move |result| { + let _ = sender.send(result); + }); + self.device + .poll(wgpu::PollType::wait_indefinitely()) + .map_err(|error| GpuError::Readback(error.to_string()))?; + receiver + .recv() + .map_err(|error| GpuError::Readback(error.to_string()))? + .map_err(|error| GpuError::Readback(error.to_string()))?; + + let padded = slice.get_mapped_range(); + let mut pixels = + vec![0u8; (unpadded_bytes_per_row * size.height) as usize]; + for row in 0..size.height as usize { + let padded_start = row * padded_bytes_per_row as usize; + let unpadded_start = row * unpadded_bytes_per_row as usize; + pixels[unpadded_start..unpadded_start + unpadded_bytes_per_row as usize] + .copy_from_slice( + &padded[padded_start..padded_start + unpadded_bytes_per_row as usize], + ); + } + drop(padded); + buffer.unmap(); + Ok(pixels) + } + pub fn instance(&self) -> &wgpu::Instance { &self.instance } diff --git a/rust/crates/gpu/src/lib.rs b/rust/crates/gpu/src/lib.rs index b6ca6c1c..0c647459 100644 --- a/rust/crates/gpu/src/lib.rs +++ b/rust/crates/gpu/src/lib.rs @@ -18,4 +18,6 @@ pub enum GpuError { CreateSurface(#[from] wgpu::CreateSurfaceError), #[error("The output surface does not support the required texture format")] UnsupportedSurfaceFormat, + #[error("Failed to read back a texture: {0}")] + Readback(String), } diff --git a/rust/crates/graphics/Cargo.toml b/rust/crates/graphics/Cargo.toml new file mode 100644 index 00000000..93a5c26b --- /dev/null +++ b/rust/crates/graphics/Cargo.toml @@ -0,0 +1,7 @@ +[package] +name = "graphics" +version = "0.1.0" +edition = "2024" + +[dependencies] +scene = { version = "0.1.0", path = "../scene" } diff --git a/rust/crates/graphics/src/lib.rs b/rust/crates/graphics/src/lib.rs new file mode 100644 index 00000000..8b137891 --- /dev/null +++ b/rust/crates/graphics/src/lib.rs @@ -0,0 +1 @@ + diff --git a/rust/crates/mask-defs/Cargo.toml b/rust/crates/mask-defs/Cargo.toml new file mode 100644 index 00000000..7306156d --- /dev/null +++ b/rust/crates/mask-defs/Cargo.toml @@ -0,0 +1,8 @@ +[package] +name = "mask-defs" +version = "0.1.0" +edition = "2024" + +[dependencies] +scene = { version = "0.1.0", path = "../scene" } +thiserror = "2" diff --git a/rust/crates/mask-defs/src/lib.rs b/rust/crates/mask-defs/src/lib.rs new file mode 100644 index 00000000..8b137891 --- /dev/null +++ b/rust/crates/mask-defs/src/lib.rs @@ -0,0 +1 @@ + diff --git a/rust/crates/media/Cargo.toml b/rust/crates/media/Cargo.toml new file mode 100644 index 00000000..2272ce17 --- /dev/null +++ b/rust/crates/media/Cargo.toml @@ -0,0 +1,9 @@ +[package] +name = "media" +version = "0.1.0" +edition = "2024" + +[dependencies] +ffmpeg-next = "8" +thiserror = "2" +time = { version = "0.1.0", path = "../time" } diff --git a/rust/crates/media/src/lib.rs b/rust/crates/media/src/lib.rs new file mode 100644 index 00000000..239f5c75 --- /dev/null +++ b/rust/crates/media/src/lib.rs @@ -0,0 +1,610 @@ +use std::collections::HashMap; +use std::path::Path; + +use ffmpeg_next as ffmpeg; +use ffmpeg::codec::context::Context as CodecContext; +use ffmpeg::format::context::Input; +use ffmpeg::format::{Pixel, Sample, sample}; +use ffmpeg::software::{resampling, scaling}; +use ffmpeg::{ChannelLayout, Rational, media}; +pub use time::MediaTime; + +const MICROS_PER_SECOND: i64 = 1_000_000; +const AUDIO_PREROLL_SECONDS: f64 = 0.5; +const FORWARD_DECODE_LIMIT_SECONDS: f64 = 10.0; +const EOF_RESEEK_MARGIN_SECONDS: f64 = 2.0; + +#[derive(Debug, thiserror::Error)] +pub enum Error { + #[error("ffmpeg error: {0}")] + Ffmpeg(#[from] ffmpeg::Error), + #[error("no video stream found")] + NoVideoStream, + #[error("no audio stream found")] + NoAudioStream, + #[error("stream index {0} does not exist")] + StreamNotFound(usize), + #[error("stream index {0} is not a video stream")] + NotAVideoStream(usize), + #[error("stream index {0} is not an audio stream")] + NotAnAudioStream(usize), + #[error("no frames could be decoded from stream {0}")] + NoFramesDecoded(usize), + #[error("invalid time range: start must be before end")] + InvalidTimeRange, + #[error("bucket count must be at least 1")] + InvalidBucketCount, + #[error("max dimension must be at least 1")] + InvalidMaxDimension, +} + +pub type Result<T, E = Error> = std::result::Result<T, E>; + +#[derive(Debug, Clone)] +pub struct VideoStreamInfo { + pub index: usize, + pub width: u32, + pub height: u32, + pub frame_rate: f64, + pub frame_count: Option<u64>, +} + +#[derive(Debug, Clone)] +pub struct AudioStreamInfo { + pub index: usize, + pub channels: u32, + pub sample_rate: u32, +} + +#[derive(Debug, Clone)] +pub struct MediaInfo { + pub duration: MediaTime, + pub video_streams: Vec<VideoStreamInfo>, + pub audio_streams: Vec<AudioStreamInfo>, +} + +#[derive(Debug, Clone, PartialEq)] +pub struct VideoFrame { + pub width: u32, + pub height: u32, + pub data: Vec<u8>, +} + +#[derive(Debug, Clone)] +pub struct AudioSamples { + pub sample_rate: u32, + pub channels: u32, + pub samples: Vec<f32>, +} + +fn seconds_to_ts(seconds: f64, time_base: Rational) -> i64 { + (seconds * f64::from(time_base.denominator()) / f64::from(time_base.numerator())).round() + as i64 +} + +fn ts_to_seconds(ts: i64, time_base: Rational) -> f64 { + ts as f64 * f64::from(time_base.numerator()) / f64::from(time_base.denominator()) +} + +fn seconds_to_micros(seconds: f64) -> i64 { + (seconds * MICROS_PER_SECOND as f64) as i64 +} + +fn effective_layout(layout: ChannelLayout, channels: u16) -> ChannelLayout { + if layout.is_empty() { + ChannelLayout::default(i32::from(channels)) + } else { + layout + } +} + +struct VideoPipeline { + stream_index: usize, + decoder: ffmpeg::codec::decoder::Video, + time_base: Rational, + last: Option<(i64, ffmpeg::frame::Video)>, + drained: bool, + scaler: Option<(Pixel, u32, u32, scaling::Context)>, +} + +impl VideoPipeline { + fn open(input: &Input, stream_index: usize) -> Result<Self> { + let stream = input + .streams() + .find(|stream| stream.index() == stream_index) + .ok_or(Error::StreamNotFound(stream_index))?; + if stream.parameters().medium() != media::Type::Video { + return Err(Error::NotAVideoStream(stream_index)); + } + let decoder = CodecContext::from_parameters(stream.parameters())? + .decoder() + .video()?; + Ok(Self { + stream_index, + decoder, + time_base: stream.time_base(), + last: None, + drained: false, + scaler: None, + }) + } + + fn frame_at( + &mut self, + input: &mut Input, + target_ts: i64, + seek_seconds: f64, + ) -> Result<ffmpeg::frame::Video> { + if let Some((last_pts, last_frame)) = &self.last { + if target_ts >= *last_pts { + if self.drained { + return Ok(last_frame.clone()); + } + let forward_limit = + seconds_to_ts(FORWARD_DECODE_LIMIT_SECONDS, self.time_base); + if target_ts - last_pts <= forward_limit { + let candidate = self.last.take(); + return self.decode_forward(input, target_ts, candidate); + } + } + } + self.decoder.flush(); + self.drained = false; + self.last = None; + let micros = seconds_to_micros(seek_seconds.max(0.0)); + input.seek(micros, ..micros)?; + match self.decode_forward(input, target_ts, None) { + Err(Error::NoFramesDecoded(_)) => { + self.decoder.flush(); + input.seek(0, ..0)?; + self.decode_forward(input, i64::MAX, None) + } + result => result, + } + } + + fn decode_forward( + &mut self, + input: &mut Input, + target_ts: i64, + mut candidate: Option<(i64, ffmpeg::frame::Video)>, + ) -> Result<ffmpeg::frame::Video> { + let mut frame = ffmpeg::frame::Video::empty(); + for (stream, packet) in input.packets() { + if stream.index() != self.stream_index { + continue; + } + self.decoder.send_packet(&packet)?; + while self.decoder.receive_frame(&mut frame).is_ok() { + let pts = frame.pts().unwrap_or(i64::MIN); + if pts <= target_ts { + candidate = Some((pts, frame.clone())); + } else { + self.last = Some((pts, frame.clone())); + return match candidate { + Some((_, frame)) => Ok(frame), + None => Ok(frame.clone()), + }; + } + frame = ffmpeg::frame::Video::empty(); + } + } + self.decoder.send_eof()?; + while self.decoder.receive_frame(&mut frame).is_ok() { + let pts = frame.pts().unwrap_or(i64::MIN); + if pts <= target_ts { + candidate = Some((pts, frame.clone())); + } else { + self.last = Some((pts, frame.clone())); + return match candidate { + Some((_, frame)) => Ok(frame), + None => Ok(frame.clone()), + }; + } + frame = ffmpeg::frame::Video::empty(); + } + self.drained = true; + match candidate { + Some((pts, frame)) => { + self.last = Some((pts, frame.clone())); + Ok(frame) + } + None => Err(Error::NoFramesDecoded(self.stream_index)), + } + } + + fn rgba_frame(&mut self, frame: &ffmpeg::frame::Video) -> Result<VideoFrame> { + convert_to_rgba( + &mut self.scaler, + frame, + frame.width(), + frame.height(), + ) + } +} + +fn convert_to_rgba( + cache: &mut Option<(Pixel, u32, u32, scaling::Context)>, + frame: &ffmpeg::frame::Video, + width: u32, + height: u32, +) -> Result<VideoFrame> { + let key = (frame.format(), frame.width(), frame.height()); + let matches = cache + .as_ref() + .is_some_and(|(format, w, h, _)| (*format, *w, *h) == key); + if !matches { + let scaler = scaling::Context::get( + frame.format(), + frame.width(), + frame.height(), + Pixel::RGBA, + width, + height, + scaling::Flags::BILINEAR, + )?; + *cache = Some((key.0, key.1, key.2, scaler)); + } + let scaler = &mut cache.as_mut().expect("scaler just inserted").3; + let mut converted = ffmpeg::frame::Video::new(Pixel::RGBA, width, height); + scaler.run(frame, &mut converted)?; + let stride = converted.stride(0); + let row_bytes = width as usize * 4; + let plane = converted.data(0); + let mut data = Vec::with_capacity(row_bytes * height as usize); + if stride == row_bytes { + data.extend_from_slice(&plane[..row_bytes * height as usize]); + } else { + for row in 0..height as usize { + data.extend_from_slice(&plane[row * stride..row * stride + row_bytes]); + } + } + Ok(VideoFrame { width, height, data }) +} + +pub struct Decoder { + input: Input, + video: HashMap<usize, VideoPipeline>, +} + +impl Decoder { + pub fn open(path: impl AsRef<Path>) -> Result<Self> { + let input = ffmpeg::format::input(path.as_ref())?; + Ok(Self { + input, + video: HashMap::new(), + }) + } + + pub fn info(&self) -> MediaInfo { + let mut video_streams = Vec::new(); + let mut audio_streams = Vec::new(); + let mut stream_duration_seconds = 0.0_f64; + for stream in self.input.streams() { + let duration = stream.duration(); + if duration > 0 { + stream_duration_seconds = + stream_duration_seconds.max(ts_to_seconds(duration, stream.time_base())); + } + match stream.parameters().medium() { + media::Type::Video => { + let Ok(context) = CodecContext::from_parameters(stream.parameters()) else { + continue; + }; + let Ok(decoder) = context.decoder().video() else { + continue; + }; + let rate = stream.avg_frame_rate(); + let frame_rate = if rate.denominator() == 0 { + 0.0 + } else { + f64::from(rate.numerator()) / f64::from(rate.denominator()) + }; + let frames = stream.frames(); + video_streams.push(VideoStreamInfo { + index: stream.index(), + width: decoder.width(), + height: decoder.height(), + frame_rate, + frame_count: (frames > 0).then_some(frames as u64), + }); + } + media::Type::Audio => { + let Ok(context) = CodecContext::from_parameters(stream.parameters()) else { + continue; + }; + let Ok(decoder) = context.decoder().audio() else { + continue; + }; + audio_streams.push(AudioStreamInfo { + index: stream.index(), + channels: u32::from(decoder.channels()), + sample_rate: decoder.rate(), + }); + } + _ => {} + } + } + let duration_micros = self.input.duration(); + let duration_seconds = if duration_micros > 0 { + duration_micros as f64 / MICROS_PER_SECOND as f64 + } else { + stream_duration_seconds + }; + MediaInfo { + duration: MediaTime::from_seconds_f64(duration_seconds).unwrap_or(MediaTime::ZERO), + video_streams, + audio_streams, + } + } + + pub fn decode_video_frame(&mut self, stream_index: usize, time: MediaTime) -> Result<VideoFrame> { + let raw = self.decode_video_raw(stream_index, time)?; + let pipeline = self + .video + .get_mut(&stream_index) + .expect("pipeline created by decode_video_raw"); + pipeline.rgba_frame(&raw) + } + + fn decode_video_raw( + &mut self, + stream_index: usize, + time: MediaTime, + ) -> Result<ffmpeg::frame::Video> { + if !self.video.contains_key(&stream_index) { + let pipeline = VideoPipeline::open(&self.input, stream_index)?; + self.video.insert(stream_index, pipeline); + } + let target_seconds = time.to_seconds_f64().max(0.0); + let duration_seconds = self.input.duration().max(0) as f64 / MICROS_PER_SECOND as f64; + let pipeline = self + .video + .get_mut(&stream_index) + .expect("pipeline just inserted"); + let mut target_ts = seconds_to_ts(target_seconds, pipeline.time_base); + let mut seek_seconds = target_seconds; + if duration_seconds > 0.0 && target_seconds > duration_seconds { + target_ts = i64::MAX; + seek_seconds = (duration_seconds - EOF_RESEEK_MARGIN_SECONDS).max(0.0); + } + pipeline.frame_at(&mut self.input, target_ts, seek_seconds) + } + + pub fn decode_audio_range( + &mut self, + stream_index: usize, + start: MediaTime, + end: MediaTime, + ) -> Result<AudioSamples> { + self.decode_audio_range_inner(stream_index, start, end, true) + } + + fn decode_audio_range_inner( + &mut self, + stream_index: usize, + start: MediaTime, + end: MediaTime, + pad_to_range: bool, + ) -> Result<AudioSamples> { + if end <= start { + return Err(Error::InvalidTimeRange); + } + let stream = self + .input + .streams() + .find(|stream| stream.index() == stream_index) + .ok_or(Error::StreamNotFound(stream_index))?; + if stream.parameters().medium() != media::Type::Audio { + return Err(Error::NotAnAudioStream(stream_index)); + } + let time_base = stream.time_base(); + let mut decoder = CodecContext::from_parameters(stream.parameters())? + .decoder() + .audio()?; + let rate = decoder.rate(); + let channels = u32::from(decoder.channels()); + let layout = effective_layout(decoder.channel_layout(), decoder.channels()); + let mut resampler = resampling::Context::get( + decoder.format(), + layout, + rate, + Sample::F32(sample::Type::Packed), + layout, + rate, + )?; + let start_seconds = start.to_seconds_f64().max(0.0); + let end_seconds = end.to_seconds_f64(); + let seek_seconds = (start_seconds - AUDIO_PREROLL_SECONDS).max(0.0); + let seek_micros = seconds_to_micros(seek_seconds); + self.input.seek(seek_micros, ..seek_micros)?; + + let rate_f64 = f64::from(rate); + let channel_count = channels as usize; + let mut anchor_seconds: Option<f64> = None; + let mut produced: i64 = 0; + let mut out_samples: Vec<f32> = Vec::new(); + let mut done = false; + + let mut frame = ffmpeg::frame::Audio::empty(); + for (packet_stream, packet) in self.input.packets() { + if packet_stream.index() != stream_index { + continue; + } + decoder.send_packet(&packet)?; + while decoder.receive_frame(&mut frame).is_ok() { + if frame.channel_layout().is_empty() { + frame.set_channel_layout(layout); + } + if anchor_seconds.is_none() { + anchor_seconds = Some(match frame.pts() { + Some(pts) => ts_to_seconds(pts, time_base), + None => seek_seconds, + }); + } + let mut converted = ffmpeg::frame::Audio::empty(); + resampler.run(&frame, &mut converted)?; + append_resampled( + &converted, + anchor_seconds.expect("anchor set above"), + start_seconds, + end_seconds, + rate_f64, + channel_count, + &mut produced, + &mut out_samples, + ); + frame = ffmpeg::frame::Audio::empty(); + let anchor = anchor_seconds.expect("anchor set above"); + let end_index = ((end_seconds - anchor) * rate_f64).round() as i64; + if produced >= end_index { + done = true; + break; + } + } + if done { + break; + } + } + if !done { + decoder.send_eof()?; + while decoder.receive_frame(&mut frame).is_ok() { + if frame.channel_layout().is_empty() { + frame.set_channel_layout(layout); + } + if anchor_seconds.is_none() { + anchor_seconds = Some(match frame.pts() { + Some(pts) => ts_to_seconds(pts, time_base), + None => seek_seconds, + }); + } + let mut converted = ffmpeg::frame::Audio::empty(); + resampler.run(&frame, &mut converted)?; + append_resampled( + &converted, + anchor_seconds.expect("anchor set above"), + start_seconds, + end_seconds, + rate_f64, + channel_count, + &mut produced, + &mut out_samples, + ); + frame = ffmpeg::frame::Audio::empty(); + } + let mut converted = ffmpeg::frame::Audio::empty(); + if resampler.flush(&mut converted).is_ok() && converted.samples() > 0 { + append_resampled( + &converted, + anchor_seconds.unwrap_or(seek_seconds), + start_seconds, + end_seconds, + rate_f64, + channel_count, + &mut produced, + &mut out_samples, + ); + } + } + if pad_to_range && let Some(anchor) = anchor_seconds { + let start_index = (((start_seconds - anchor) * rate_f64).round() as i64).max(0); + let end_index = (((end_seconds - anchor) * rate_f64).round() as i64).max(start_index); + let expected = ((end_index - start_index) as usize) * channel_count; + if out_samples.len() < expected { + out_samples.resize(expected, 0.0); + } + } + Ok(AudioSamples { + sample_rate: rate, + channels, + samples: out_samples, + }) + } +} + +fn append_resampled( + converted: &ffmpeg::frame::Audio, + anchor_seconds: f64, + start_seconds: f64, + end_seconds: f64, + rate: f64, + channels: usize, + produced: &mut i64, + out_samples: &mut Vec<f32>, +) { + let count = converted.samples() as i64; + if count == 0 { + return; + } + let start_index = (((start_seconds - anchor_seconds) * rate).round() as i64).max(0); + let end_index = (((end_seconds - anchor_seconds) * rate).round() as i64).max(start_index); + let low = (*produced).max(start_index); + let high = (*produced + count).min(end_index); + if low < high { + if out_samples.is_empty() && low > start_index { + out_samples.resize(((low - start_index) as usize) * channels, 0.0); + } + let data = converted.data(0); + let first = (low - *produced) as usize * channels; + let last = (high - *produced) as usize * channels; + for bytes in data[first * 4..last * 4].chunks_exact(4) { + out_samples.push(f32::from_le_bytes([bytes[0], bytes[1], bytes[2], bytes[3]])); + } + } + *produced += count; +} + +pub fn thumbnail(path: impl AsRef<Path>, max_dimension: u32) -> Result<VideoFrame> { + if max_dimension == 0 { + return Err(Error::InvalidMaxDimension); + } + let mut decoder = Decoder::open(path.as_ref())?; + let video_stream = decoder + .info() + .video_streams + .first() + .cloned() + .ok_or(Error::NoVideoStream)?; + let raw = decoder.decode_video_raw(video_stream.index, MediaTime::ZERO)?; + let source_max = raw.width().max(raw.height()); + let scale = (f64::from(max_dimension) / f64::from(source_max)).min(1.0); + let width = ((f64::from(raw.width()) * scale).round() as u32).max(1); + let height = ((f64::from(raw.height()) * scale).round() as u32).max(1); + let mut cache = None; + convert_to_rgba(&mut cache, &raw, width, height) +} + +pub fn waveform_summary(path: impl AsRef<Path>, bucket_count: usize) -> Result<Vec<(f32, f32)>> { + if bucket_count == 0 { + return Err(Error::InvalidBucketCount); + } + let mut decoder = Decoder::open(path.as_ref())?; + let info = decoder.info(); + let audio_stream = info.audio_streams.first().ok_or(Error::NoAudioStream)?; + let samples = decoder.decode_audio_range_inner( + audio_stream.index, + MediaTime::ZERO, + info.duration, + false, + )?; + let channels = samples.channels as usize; + let frame_count = samples.samples.len() / channels; + let bucket_size = frame_count.div_ceil(bucket_count).max(1); + Ok((0..bucket_count) + .map(|bucket| { + let start_frame = bucket * bucket_size; + let end_frame = (start_frame + bucket_size).min(frame_count); + if start_frame >= end_frame { + return (0.0, 0.0); + } + let slice = &samples.samples[start_frame * channels..end_frame * channels]; + let mut low = f32::INFINITY; + let mut high = f32::NEG_INFINITY; + for &sample in slice { + low = low.min(sample); + high = high.max(sample); + } + (low, high) + }) + .collect()) +} diff --git a/rust/crates/media/tests/decode.rs b/rust/crates/media/tests/decode.rs new file mode 100644 index 00000000..7fcd6cd5 --- /dev/null +++ b/rust/crates/media/tests/decode.rs @@ -0,0 +1,193 @@ +use media::{Decoder, Error, MediaTime, thumbnail, waveform_summary}; + +const SAMPLE_MP4: &str = concat!(env!("CARGO_MANIFEST_DIR"), "/tests/fixtures/sample.mp4"); +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") +} + +#[test] +fn reports_media_info() { + let decoder = Decoder::open(SAMPLE_MP4).expect("open sample.mp4"); + let info = decoder.info(); + let duration_seconds = info.duration.to_seconds_f64(); + assert!( + (duration_seconds - 2.0).abs() < 0.05, + "duration was {duration_seconds}" + ); + let video = info.video_streams.first().expect("video stream"); + assert_eq!(video.width, 320); + assert_eq!(video.height, 180); + assert!( + (video.frame_rate - 30.0).abs() < 0.01, + "frame rate was {}", + video.frame_rate + ); + assert_eq!(video.frame_count, Some(60)); + let audio = info.audio_streams.first().expect("audio stream"); + assert_eq!(audio.sample_rate, 44100); + assert!(audio.channels >= 1); +} + +#[test] +fn decodes_video_frame_at_zero_and_one_second() { + let mut decoder = Decoder::open(SAMPLE_MP4).expect("open sample.mp4"); + let first = decoder + .decode_video_frame(0, seconds(0.0)) + .expect("frame at t=0"); + assert_eq!(first.width, 320); + assert_eq!(first.height, 180); + assert_eq!(first.data.len(), 320 * 180 * 4); + let first_pixel = &first.data[0..4]; + assert!( + first.data.chunks_exact(4).any(|pixel| pixel != first_pixel), + "frame at t=0 is uniform" + ); + let later = decoder + .decode_video_frame(0, seconds(1.0)) + .expect("frame at t=1"); + assert_eq!(later.width, 320); + assert_eq!(later.height, 180); + assert!( + later.data.chunks_exact(4).any(|pixel| pixel != first_pixel), + "frame at t=1 is uniform" + ); + assert_ne!(first.data, later.data, "frames at t=0 and t=1 are identical"); +} + +#[test] +fn decodes_sequential_frames_forward() { + let mut decoder = Decoder::open(SAMPLE_MP4).expect("open sample.mp4"); + let mut previous = decoder.decode_video_frame(0, seconds(0.25)).expect("t=0.25"); + for step in 1..8 { + let time = 0.25 + f64::from(step) * 0.2; + let frame = decoder.decode_video_frame(0, seconds(time)).expect("frame"); + assert_eq!((frame.width, frame.height), (320, 180)); + previous = frame; + } + let _ = previous; +} + +#[test] +fn time_past_eof_returns_last_frame() { + let mut decoder = Decoder::open(SAMPLE_MP4).expect("open sample.mp4"); + let frame = decoder + .decode_video_frame(0, seconds(10.0)) + .expect("frame past EOF"); + assert_eq!(frame.width, 320); + assert_eq!(frame.height, 180); + assert_eq!(frame.data.len(), 320 * 180 * 4); +} + +#[test] +fn rejects_wrong_stream_type() { + let mut decoder = Decoder::open(SAMPLE_MP4).expect("open sample.mp4"); + let info = decoder.info(); + let audio_index = info.audio_streams.first().expect("audio stream").index; + let result = decoder.decode_video_frame(audio_index, seconds(0.0)); + assert!(matches!(result, Err(Error::NotAVideoStream(_)))); + let video_index = info.video_streams.first().expect("video stream").index; + let result = decoder.decode_audio_range(video_index, seconds(0.0), seconds(1.0)); + assert!(matches!(result, Err(Error::NotAnAudioStream(_)))); + let result = decoder.decode_video_frame(99, seconds(0.0)); + assert!(matches!(result, Err(Error::StreamNotFound(99)))); +} + +#[test] +fn decodes_tone_waveform() { + let mut decoder = Decoder::open(TONE_WAV).expect("open tone.wav"); + let info = decoder.info(); + let audio = info.audio_streams.first().expect("audio stream"); + assert_eq!(audio.sample_rate, 44100); + let decoded = decoder + .decode_audio_range(audio.index, seconds(0.0), seconds(1.0)) + .expect("decode range"); + assert_eq!(decoded.sample_rate, 44100); + let channels = decoded.channels as usize; + let frame_count = decoded.samples.len() / channels; + assert!( + (frame_count as i64 - 44100).abs() <= 2, + "frame count was {frame_count}" + ); + let mean_square: f32 = + decoded.samples.iter().map(|sample| sample * sample).sum::<f32>() + / decoded.samples.len() as f32; + assert!(mean_square.sqrt() > 0.05, "rms was {}", mean_square.sqrt()); + assert!( + decoded.samples.iter().all(|sample| sample.abs() <= 1.0), + "samples exceed [-1, 1]" + ); + let mut crossings = 0usize; + let mut previous_sign = decoded.samples[0].is_sign_negative(); + for &sample in &decoded.samples[1..] { + let sign = sample.is_sign_negative(); + if sign != previous_sign { + crossings += 1; + previous_sign = sign; + } + } + assert!( + (crossings as i64 - 440).abs() <= 22, + "zero crossings were {crossings}" + ); +} + +#[test] +fn decodes_aac_audio_range() { + let mut decoder = Decoder::open(SAMPLE_MP4).expect("open sample.mp4"); + let info = decoder.info(); + let audio = info.audio_streams.first().expect("audio stream"); + let decoded = decoder + .decode_audio_range(audio.index, seconds(0.5), seconds(1.5)) + .expect("decode aac range"); + assert_eq!(decoded.sample_rate, 44100); + let channels = decoded.channels as usize; + let frame_count = decoded.samples.len() / channels; + assert!( + (frame_count as i64 - 44100).abs() <= 100, + "frame count was {frame_count}" + ); + let mean_square: f32 = + decoded.samples.iter().map(|sample| sample * sample).sum::<f32>() + / decoded.samples.len() as f32; + assert!(mean_square.sqrt() > 0.01, "rms was {}", mean_square.sqrt()); +} + +#[test] +fn rejects_invalid_audio_range() { + let mut decoder = Decoder::open(TONE_WAV).expect("open tone.wav"); + let result = decoder.decode_audio_range(0, seconds(1.0), seconds(0.5)); + assert!(matches!(result, Err(Error::InvalidTimeRange))); +} + +#[test] +fn scales_thumbnail_to_max_dimension() { + let thumb = thumbnail(SAMPLE_MP4, 64).expect("thumbnail"); + assert_eq!(thumb.width, 64); + assert_eq!(thumb.height, 36); + assert_eq!(thumb.data.len(), 64 * 36 * 4); + let first_pixel = &thumb.data[0..4]; + assert!( + thumb.data.chunks_exact(4).any(|pixel| pixel != first_pixel), + "thumbnail is uniform" + ); +} + +#[test] +fn summarizes_waveform_buckets() { + let buckets = waveform_summary(TONE_WAV, 10).expect("waveform summary"); + assert_eq!(buckets.len(), 10); + for (low, high) in &buckets { + assert!(low <= high, "bucket min {low} exceeds max {high}"); + assert!(*low >= -1.0 && *high <= 1.0, "bucket ({low}, {high}) out of range"); + } + assert!( + buckets.iter().any(|(low, high)| *high > 0.1 && *low < -0.1), + "no bucket carries the 220Hz tone" + ); + let single = waveform_summary(TONE_WAV, 1).expect("single bucket"); + assert_eq!(single.len(), 1); + assert!(single[0].1 > 0.1); + assert!(matches!(waveform_summary(TONE_WAV, 0), Err(Error::InvalidBucketCount))); +} diff --git a/rust/crates/media/tests/fixtures/sample.mp4 b/rust/crates/media/tests/fixtures/sample.mp4 Binary files differnew file mode 100644 index 00000000..13c113e5 --- /dev/null +++ b/rust/crates/media/tests/fixtures/sample.mp4 diff --git a/rust/crates/media/tests/fixtures/tone.wav b/rust/crates/media/tests/fixtures/tone.wav Binary files differnew file mode 100644 index 00000000..8d5f7886 --- /dev/null +++ b/rust/crates/media/tests/fixtures/tone.wav diff --git a/rust/crates/playback/Cargo.toml b/rust/crates/playback/Cargo.toml new file mode 100644 index 00000000..f1edb8f7 --- /dev/null +++ b/rust/crates/playback/Cargo.toml @@ -0,0 +1,7 @@ +[package] +name = "playback" +version = "0.1.0" +edition = "2024" + +[dependencies] +time = { version = "0.1.0", path = "../time" } diff --git a/rust/crates/playback/src/lib.rs b/rust/crates/playback/src/lib.rs new file mode 100644 index 00000000..2e10e818 --- /dev/null +++ b/rust/crates/playback/src/lib.rs @@ -0,0 +1,316 @@ +use time::{FrameRate, MediaTime}; + +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub enum PlaybackEvent { + Started, + Paused, + Seeked(MediaTime), + Updated(MediaTime), + ReachedEnd(MediaTime), +} + +pub struct PlaybackClock { + is_playing: bool, + current_time: MediaTime, + total_duration: MediaTime, + frame_rate: Option<FrameRate>, + anchor_media: MediaTime, + anchor_wall_seconds: f64, + volume: f64, + previous_volume: f64, + muted: bool, + is_scrubbing: bool, +} + +impl Default for PlaybackClock { + fn default() -> Self { + Self::new() + } +} + +impl PlaybackClock { + pub fn new() -> Self { + Self { + is_playing: false, + current_time: MediaTime::ZERO, + total_duration: MediaTime::ZERO, + frame_rate: None, + anchor_media: MediaTime::ZERO, + anchor_wall_seconds: 0.0, + volume: 1.0, + previous_volume: 1.0, + muted: false, + is_scrubbing: false, + } + } + + pub fn is_playing(&self) -> bool { + self.is_playing + } + + pub fn current_time(&self) -> MediaTime { + self.current_time + } + + pub fn total_duration(&self) -> MediaTime { + self.total_duration + } + + pub fn volume(&self) -> f64 { + self.volume + } + + pub fn is_muted(&self) -> bool { + self.muted + } + + pub fn is_scrubbing(&self) -> bool { + self.is_scrubbing + } + + pub fn set_total_duration(&mut self, duration: MediaTime) -> Vec<PlaybackEvent> { + self.total_duration = duration; + let clamped = self.clamp(self.current_time); + let reached_end = self.is_playing && clamped >= self.total_duration; + if clamped == self.current_time && !reached_end { + return Vec::new(); + } + self.current_time = clamped; + let mut events = vec![PlaybackEvent::Seeked(clamped)]; + if reached_end { + self.is_playing = false; + events.push(PlaybackEvent::Paused); + } + events + } + + pub fn set_frame_rate(&mut self, frame_rate: Option<FrameRate>) { + self.frame_rate = frame_rate; + } + + pub fn play(&mut self, wall_seconds: f64) -> Vec<PlaybackEvent> { + if self.total_duration <= MediaTime::ZERO { + return Vec::new(); + } + let mut events = Vec::new(); + if self.current_time >= self.total_duration { + self.current_time = MediaTime::ZERO; + events.push(PlaybackEvent::Seeked(MediaTime::ZERO)); + } + self.is_playing = true; + self.anchor_media = self.current_time; + self.anchor_wall_seconds = wall_seconds; + events.push(PlaybackEvent::Started); + events + } + + pub fn pause(&mut self) -> Vec<PlaybackEvent> { + if !self.is_playing { + return Vec::new(); + } + self.is_playing = false; + vec![PlaybackEvent::Paused] + } + + pub fn toggle(&mut self, wall_seconds: f64) -> Vec<PlaybackEvent> { + if self.is_playing { + self.pause() + } else { + self.play(wall_seconds) + } + } + + pub fn seek(&mut self, time: MediaTime, wall_seconds: f64) -> Vec<PlaybackEvent> { + self.current_time = self.clamp(time); + if self.is_playing { + self.anchor_media = self.current_time; + self.anchor_wall_seconds = wall_seconds; + } + vec![PlaybackEvent::Seeked(self.current_time)] + } + + pub fn tick(&mut self, wall_seconds: f64) -> Vec<PlaybackEvent> { + if !self.is_playing { + return Vec::new(); + } + let elapsed = wall_seconds - self.anchor_wall_seconds; + let Some(elapsed_time) = MediaTime::from_seconds_f64(elapsed) else { + return Vec::new(); + }; + let raw = MediaTime::from_ticks( + self.anchor_media.as_ticks().saturating_add(elapsed_time.as_ticks()), + ); + let rounded = self.round_to_frame(raw); + + if rounded >= self.total_duration { + self.is_playing = false; + self.current_time = self.total_duration; + return vec![ + PlaybackEvent::Paused, + PlaybackEvent::ReachedEnd(self.total_duration), + ]; + } + if rounded == self.current_time { + return Vec::new(); + } + self.current_time = rounded; + vec![PlaybackEvent::Updated(rounded)] + } + + pub fn set_volume(&mut self, volume: f64) { + let clamped = volume.clamp(0.0, 1.0); + self.volume = clamped; + self.muted = clamped == 0.0; + if clamped > 0.0 { + self.previous_volume = clamped; + } + } + + pub fn mute(&mut self) { + if self.volume > 0.0 { + self.previous_volume = self.volume; + } + self.muted = true; + self.volume = 0.0; + } + + pub fn unmute(&mut self) { + self.muted = false; + self.volume = self.previous_volume; + } + + pub fn toggle_mute(&mut self) { + if self.muted { + self.unmute(); + } else { + self.mute(); + } + } + + pub fn set_scrubbing(&mut self, is_scrubbing: bool) { + self.is_scrubbing = is_scrubbing; + } + + fn clamp(&self, time: MediaTime) -> MediaTime { + time.clamp(MediaTime::ZERO, self.total_duration) + } + + fn round_to_frame(&self, time: MediaTime) -> MediaTime { + let Some(rate) = self.frame_rate else { + return time; + }; + let Some(frame) = time.to_frame_round(rate) else { + return time; + }; + MediaTime::from_frame(frame, rate).unwrap_or(time) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + const FPS_30: FrameRate = FrameRate { + numerator: 30, + denominator: 1, + }; + + fn clock() -> PlaybackClock { + let mut clock = PlaybackClock::new(); + clock.set_frame_rate(Some(FPS_30)); + clock.set_total_duration(MediaTime::from_seconds_f64(4.0).unwrap()); + clock + } + + #[test] + fn play_at_end_restarts_from_zero() { + let mut clock = clock(); + clock.seek(MediaTime::from_seconds_f64(10.0).unwrap(), 0.0); + let events = clock.play(0.0); + assert!(events.contains(&PlaybackEvent::Seeked(MediaTime::ZERO))); + assert!(events.contains(&PlaybackEvent::Started)); + } + + #[test] + fn play_with_empty_timeline_does_nothing() { + let mut clock = PlaybackClock::new(); + assert!(clock.play(0.0).is_empty()); + assert!(!clock.is_playing()); + } + + #[test] + fn tick_rounds_to_frames() { + let mut clock = clock(); + clock.play(0.0); + let events = clock.tick(0.5); + let expected = MediaTime::from_frame(15, FPS_30).unwrap(); + assert_eq!(events, vec![PlaybackEvent::Updated(expected)]); + assert_eq!(clock.current_time(), expected); + } + + #[test] + fn tick_stops_at_end() { + let mut clock = clock(); + clock.play(0.0); + let events = clock.tick(10.0); + assert!(events.contains(&PlaybackEvent::Paused)); + assert_eq!(clock.current_time(), clock.total_duration()); + assert!(!clock.is_playing()); + } + + #[test] + fn tick_same_frame_emits_nothing() { + let mut clock = clock(); + clock.play(0.0); + assert!(clock.tick(0.001).is_empty()); + } + + #[test] + fn seek_clamps_into_timeline() { + let mut clock = clock(); + clock.seek(MediaTime::from_seconds_f64(99.0).unwrap(), 0.0); + assert_eq!(clock.current_time(), clock.total_duration()); + } + + #[test] + fn seek_while_playing_reanchors() { + let mut clock = clock(); + clock.play(0.0); + clock.seek(MediaTime::from_seconds_f64(1.0).unwrap(), 5.0); + let events = clock.tick(6.0); + let expected = MediaTime::from_frame(60, FPS_30).unwrap(); + assert_eq!(events, vec![PlaybackEvent::Updated(expected)]); + } + + #[test] + fn volume_mute_cycle_restores_previous() { + let mut clock = clock(); + clock.set_volume(0.7); + clock.mute(); + assert_eq!(clock.volume(), 0.0); + assert!(clock.is_muted()); + clock.unmute(); + assert_eq!(clock.volume(), 0.7); + assert!(!clock.is_muted()); + } + + #[test] + fn zero_volume_counts_as_muted() { + let mut clock = clock(); + clock.set_volume(0.5); + clock.set_volume(0.0); + assert!(clock.is_muted()); + clock.unmute(); + assert_eq!(clock.volume(), 0.5); + } + + #[test] + fn shrinking_duration_pauses_at_end() { + let mut clock = clock(); + clock.play(0.0); + clock.tick(2.0); + let events = clock.set_total_duration(MediaTime::from_seconds_f64(1.0).unwrap()); + assert!(events.contains(&PlaybackEvent::Paused)); + assert_eq!(clock.current_time(), MediaTime::from_seconds_f64(1.0).unwrap()); + } +} diff --git a/rust/crates/scene/Cargo.toml b/rust/crates/scene/Cargo.toml new file mode 100644 index 00000000..f81e1457 --- /dev/null +++ b/rust/crates/scene/Cargo.toml @@ -0,0 +1,10 @@ +[package] +name = "scene" +version = "0.1.0" +edition = "2024" + +[dependencies] +chrono = { version = "0.4", features = ["serde"] } +serde = { version = "1.0", features = ["derive"] } +serde_json = "1.0" +time = { version = "0.1.0", path = "../time" } diff --git a/rust/crates/scene/src/animation.rs b/rust/crates/scene/src/animation.rs new file mode 100644 index 00000000..d09dd3de --- /dev/null +++ b/rust/crates/scene/src/animation.rs @@ -0,0 +1,127 @@ +use serde::{Deserialize, Serialize}; +use std::collections::BTreeMap; +use time::MediaTime; + +use crate::params::ParamValue; + +pub const ANIMATION_PROPERTY_PATHS: &[&str] = &[ + "transform.positionX", + "transform.positionY", + "transform.scaleX", + "transform.scaleY", + "transform.rotate", + "opacity", + "volume", + "color", + "background.color", + "background.paddingX", + "background.paddingY", + "background.offsetX", + "background.offsetY", + "background.cornerRadius", +]; + +#[derive(Serialize, Deserialize, Clone, Copy, Debug, Eq, PartialEq)] +#[serde(rename_all = "camelCase")] +pub enum ScalarSegmentType { + Step, + Linear, + Bezier, +} + +#[derive(Serialize, Deserialize, Clone, Copy, Debug, Eq, PartialEq)] +#[serde(rename_all = "camelCase")] +pub enum TangentMode { + Auto, + Aligned, + Broken, + Flat, +} + +#[derive(Serialize, Deserialize, Clone, Copy, Debug, Eq, PartialEq)] +#[serde(rename_all = "camelCase")] +pub enum ChannelExtrapolationMode { + Hold, + Linear, +} + +#[derive(Serialize, Deserialize, Clone, Copy, Debug, PartialEq)] +pub struct CurveHandle { + pub dt: MediaTime, + pub dv: f64, +} + +#[derive(Serialize, Deserialize, Clone, Debug, PartialEq)] +#[serde(rename_all = "camelCase")] +pub struct ScalarAnimationKey { + pub id: String, + pub time: MediaTime, + pub value: f64, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub left_handle: Option<CurveHandle>, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub right_handle: Option<CurveHandle>, + pub segment_to_next: ScalarSegmentType, + pub tangent_mode: TangentMode, +} + +#[derive(Serialize, Deserialize, Clone, Debug, PartialEq)] +#[serde(untagged)] +pub enum DiscreteValue { + Bool(bool), + String(String), +} + +#[derive(Serialize, Deserialize, Clone, Debug, PartialEq)] +#[serde(rename_all = "camelCase")] +pub struct DiscreteAnimationKey { + pub id: String, + pub time: MediaTime, + pub value: DiscreteValue, +} + +#[derive(Serialize, Deserialize, Clone, Debug, PartialEq)] +#[serde(rename_all = "camelCase")] +pub struct ChannelExtrapolation { + pub before: ChannelExtrapolationMode, + pub after: ChannelExtrapolationMode, +} + +#[derive(Serialize, Deserialize, Clone, Debug, PartialEq)] +pub struct ScalarChannel { + pub keys: Vec<ScalarAnimationKey>, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub extrapolation: Option<ChannelExtrapolation>, +} + +#[derive(Serialize, Deserialize, Clone, Debug, PartialEq)] +pub struct DiscreteChannel { + pub keys: Vec<DiscreteAnimationKey>, +} + +#[derive(Serialize, Deserialize, Clone, Debug, PartialEq)] +#[serde(untagged)] +pub enum AnimationChannel { + Scalar(ScalarChannel), + Discrete(DiscreteChannel), +} + +pub type CompositeChannelData = BTreeMap<String, AnimationChannel>; + +#[derive(Serialize, Deserialize, Clone, Debug, PartialEq)] +#[serde(untagged)] +pub enum ChannelData { + Single(AnimationChannel), + Composite(CompositeChannelData), +} + +pub type ElementAnimations = BTreeMap<String, ChannelData>; + +impl From<DiscreteValue> for ParamValue { + fn from(value: DiscreteValue) -> Self { + match value { + DiscreteValue::Bool(b) => ParamValue::Bool(b), + DiscreteValue::String(s) => ParamValue::String(s), + } + } +} diff --git a/rust/crates/scene/src/effect.rs b/rust/crates/scene/src/effect.rs new file mode 100644 index 00000000..f15a6f60 --- /dev/null +++ b/rust/crates/scene/src/effect.rs @@ -0,0 +1,13 @@ +use serde::{Deserialize, Serialize}; + +use crate::params::ParamValues; + +#[derive(Serialize, Deserialize, Clone, Debug, PartialEq)] +pub struct Effect { + pub id: String, + #[serde(rename = "type")] + pub effect_type: String, + #[serde(default)] + pub params: ParamValues, + pub enabled: bool, +} diff --git a/rust/crates/scene/src/element.rs b/rust/crates/scene/src/element.rs new file mode 100644 index 00000000..ef6980f8 --- /dev/null +++ b/rust/crates/scene/src/element.rs @@ -0,0 +1,187 @@ +use serde::{Deserialize, Serialize}; +use time::MediaTime; + +use crate::animation::ElementAnimations; +use crate::effect::Effect; +use crate::mask::Mask; +use crate::params::ParamValues; + +#[derive(Serialize, Deserialize, Clone, Debug, PartialEq)] +#[serde(rename_all = "camelCase")] +pub struct ElementRef { + pub track_id: String, + pub element_id: String, +} + +#[derive(Serialize, Deserialize, Clone, Copy, Debug, PartialEq)] +#[serde(rename_all = "camelCase")] +pub struct RetimeConfig { + pub rate: f64, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub maintain_pitch: Option<bool>, +} + +#[derive(Serialize, Deserialize, Clone, Debug, PartialEq)] +#[serde(rename_all = "camelCase")] +pub struct BaseTimelineElement { + pub id: String, + pub name: String, + pub duration: MediaTime, + pub start_time: MediaTime, + pub trim_start: MediaTime, + pub trim_end: MediaTime, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub source_duration: Option<MediaTime>, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub animations: Option<ElementAnimations>, + #[serde(default)] + pub params: ParamValues, +} + +#[derive(Serialize, Deserialize, Clone, Debug, PartialEq)] +#[serde(rename_all = "camelCase")] +pub struct VideoElement { + #[serde(flatten)] + pub base: BaseTimelineElement, + pub media_id: String, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub is_source_audio_enabled: Option<bool>, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub hidden: Option<bool>, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub retime: Option<RetimeConfig>, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub effects: Option<Vec<Effect>>, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub masks: Option<Vec<Mask>>, +} + +#[derive(Serialize, Deserialize, Clone, Debug, PartialEq)] +#[serde(rename_all = "camelCase")] +pub struct ImageElement { + #[serde(flatten)] + pub base: BaseTimelineElement, + pub media_id: String, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub hidden: Option<bool>, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub effects: Option<Vec<Effect>>, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub masks: Option<Vec<Mask>>, +} + +#[derive(Serialize, Deserialize, Clone, Debug, PartialEq)] +#[serde(rename_all = "camelCase")] +pub struct TextElement { + #[serde(flatten)] + pub base: BaseTimelineElement, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub hidden: Option<bool>, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub effects: Option<Vec<Effect>>, +} + +#[derive(Serialize, Deserialize, Clone, Debug, PartialEq)] +#[serde(rename_all = "camelCase")] +pub struct StickerElement { + #[serde(flatten)] + pub base: BaseTimelineElement, + pub sticker_id: String, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub intrinsic_width: Option<f64>, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub intrinsic_height: Option<f64>, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub hidden: Option<bool>, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub effects: Option<Vec<Effect>>, +} + +#[derive(Serialize, Deserialize, Clone, Debug, PartialEq)] +#[serde(rename_all = "camelCase")] +pub struct GraphicElement { + #[serde(flatten)] + pub base: BaseTimelineElement, + pub definition_id: String, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub hidden: Option<bool>, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub effects: Option<Vec<Effect>>, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub masks: Option<Vec<Mask>>, +} + +#[derive(Serialize, Deserialize, Clone, Debug, PartialEq)] +#[serde(rename_all = "camelCase")] +pub struct EffectElement { + #[serde(flatten)] + pub base: BaseTimelineElement, + pub effect_type: String, +} + +#[derive(Serialize, Deserialize, Clone, Debug, PartialEq)] +#[serde(rename_all = "camelCase")] +pub struct UploadAudioElement { + #[serde(flatten)] + pub base: BaseTimelineElement, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub retime: Option<RetimeConfig>, + pub media_id: String, +} + +#[derive(Serialize, Deserialize, Clone, Debug, PartialEq)] +#[serde(rename_all = "camelCase")] +pub struct LibraryAudioElement { + #[serde(flatten)] + pub base: BaseTimelineElement, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub retime: Option<RetimeConfig>, + pub source_url: String, +} + +#[derive(Serialize, Deserialize, Clone, Debug, PartialEq)] +#[serde(tag = "sourceType", rename_all = "camelCase")] +pub enum AudioElement { + Upload(UploadAudioElement), + Library(LibraryAudioElement), +} + +#[derive(Serialize, Deserialize, Clone, Debug, PartialEq)] +#[serde(tag = "type", rename_all = "camelCase")] +pub enum Element { + Audio(AudioElement), + Video(VideoElement), + Image(ImageElement), + Text(TextElement), + Sticker(StickerElement), + Graphic(GraphicElement), + Effect(EffectElement), +} + +impl Element { + pub fn base(&self) -> &BaseTimelineElement { + match self { + Element::Audio(AudioElement::Upload(e)) => &e.base, + Element::Audio(AudioElement::Library(e)) => &e.base, + Element::Video(e) => &e.base, + Element::Image(e) => &e.base, + Element::Text(e) => &e.base, + Element::Sticker(e) => &e.base, + Element::Graphic(e) => &e.base, + Element::Effect(e) => &e.base, + } + } + + pub fn base_mut(&mut self) -> &mut BaseTimelineElement { + match self { + Element::Audio(AudioElement::Upload(e)) => &mut e.base, + Element::Audio(AudioElement::Library(e)) => &mut e.base, + Element::Video(e) => &mut e.base, + Element::Image(e) => &mut e.base, + Element::Text(e) => &mut e.base, + Element::Sticker(e) => &mut e.base, + Element::Graphic(e) => &mut e.base, + Element::Effect(e) => &mut e.base, + } + } +} diff --git a/rust/crates/scene/src/iso_date.rs b/rust/crates/scene/src/iso_date.rs new file mode 100644 index 00000000..82fb7828 --- /dev/null +++ b/rust/crates/scene/src/iso_date.rs @@ -0,0 +1,19 @@ +use serde::{Deserialize, Deserializer, Serializer}; +use chrono::{DateTime, SecondsFormat, Utc}; + +pub fn serialize<S>(date: &DateTime<Utc>, serializer: S) -> Result<S::Ok, S::Error> +where + S: Serializer, +{ + serializer.serialize_str(&date.to_rfc3339_opts(SecondsFormat::Millis, true)) +} + +pub fn deserialize<'de, D>(deserializer: D) -> Result<DateTime<Utc>, D::Error> +where + D: Deserializer<'de>, +{ + let s = String::deserialize(deserializer)?; + DateTime::parse_from_rfc3339(&s) + .map(|d| d.with_timezone(&Utc)) + .map_err(serde::de::Error::custom) +} diff --git a/rust/crates/scene/src/lib.rs b/rust/crates/scene/src/lib.rs new file mode 100644 index 00000000..d18925ff --- /dev/null +++ b/rust/crates/scene/src/lib.rs @@ -0,0 +1,18 @@ +pub mod animation; +pub mod effect; +pub mod element; +pub mod iso_date; +pub mod mask; +pub mod params; +pub mod project; +pub mod scene; +pub mod track; + +pub use animation::*; +pub use effect::*; +pub use element::*; +pub use mask::*; +pub use params::*; +pub use project::*; +pub use scene::*; +pub use track::*; diff --git a/rust/crates/scene/src/mask.rs b/rust/crates/scene/src/mask.rs new file mode 100644 index 00000000..12f026d2 --- /dev/null +++ b/rust/crates/scene/src/mask.rs @@ -0,0 +1,128 @@ +use serde::{Deserialize, Serialize}; + +#[derive(Serialize, Deserialize, Clone, Copy, Debug, Eq, PartialEq)] +#[serde(rename_all = "camelCase")] +pub enum StrokeAlign { + Inside, + Center, + Outside, +} + +#[derive(Serialize, Deserialize, Clone, Copy, Debug, Eq, PartialEq)] +pub enum TextFontWeight { + #[serde(rename = "normal")] + Normal, + #[serde(rename = "bold")] + Bold, +} + +#[derive(Serialize, Deserialize, Clone, Copy, Debug, Eq, PartialEq)] +pub enum TextFontStyle { + #[serde(rename = "normal")] + Normal, + #[serde(rename = "italic")] + Italic, +} + +#[derive(Serialize, Deserialize, Clone, Copy, Debug, Eq, PartialEq)] +pub enum TextDecoration { + #[serde(rename = "none")] + None, + #[serde(rename = "underline")] + Underline, + #[serde(rename = "line-through")] + LineThrough, +} + +#[derive(Serialize, Deserialize, Clone, Debug, PartialEq)] +#[serde(rename_all = "camelCase")] +pub struct FreeformPathPoint { + pub id: String, + pub x: f64, + pub y: f64, + pub in_x: f64, + pub in_y: f64, + pub out_x: f64, + pub out_y: f64, +} + +#[derive(Serialize, Deserialize, Clone, Debug, PartialEq)] +#[serde(rename_all = "camelCase")] +pub struct SplitMaskParams { + pub feather: f64, + pub inverted: bool, + pub stroke_color: String, + pub stroke_width: f64, + pub stroke_align: StrokeAlign, + pub center_x: f64, + pub center_y: f64, + pub rotation: f64, +} + +#[derive(Serialize, Deserialize, Clone, Debug, PartialEq)] +#[serde(rename_all = "camelCase")] +pub struct RectangleMaskParams { + pub feather: f64, + pub inverted: bool, + pub stroke_color: String, + pub stroke_width: f64, + pub stroke_align: StrokeAlign, + pub center_x: f64, + pub center_y: f64, + pub width: f64, + pub height: f64, + pub rotation: f64, + pub scale: f64, +} + +#[derive(Serialize, Deserialize, Clone, Debug, PartialEq)] +#[serde(rename_all = "camelCase")] +pub struct TextMaskParams { + pub feather: f64, + pub inverted: bool, + pub stroke_color: String, + pub stroke_width: f64, + pub stroke_align: StrokeAlign, + pub content: String, + pub font_size: f64, + pub font_family: String, + pub font_weight: TextFontWeight, + pub font_style: TextFontStyle, + pub text_decoration: TextDecoration, + pub letter_spacing: f64, + pub line_height: f64, + pub center_x: f64, + pub center_y: f64, + pub rotation: f64, + pub scale: f64, +} + +#[derive(Serialize, Deserialize, Clone, Debug, PartialEq)] +#[serde(rename_all = "camelCase")] +pub struct FreeformPathMaskParams { + pub feather: f64, + pub inverted: bool, + pub stroke_color: String, + pub stroke_width: f64, + pub stroke_align: StrokeAlign, + pub path: Vec<FreeformPathPoint>, + pub closed: bool, + pub center_x: f64, + pub center_y: f64, + pub rotation: f64, + pub scale: f64, +} + +#[derive(Serialize, Deserialize, Clone, Debug, PartialEq)] +#[serde(tag = "type", rename_all = "kebab-case")] +pub enum Mask { + Split { id: String, params: SplitMaskParams }, + CinematicBars { id: String, params: RectangleMaskParams }, + Rectangle { id: String, params: RectangleMaskParams }, + Ellipse { id: String, params: RectangleMaskParams }, + Heart { id: String, params: RectangleMaskParams }, + Diamond { id: String, params: RectangleMaskParams }, + Star { id: String, params: RectangleMaskParams }, + Text { id: String, params: TextMaskParams }, + Freeform { id: String, params: FreeformPathMaskParams }, +} diff --git a/rust/crates/scene/src/params.rs b/rust/crates/scene/src/params.rs new file mode 100644 index 00000000..e59c16b5 --- /dev/null +++ b/rust/crates/scene/src/params.rs @@ -0,0 +1,12 @@ +use serde::{Deserialize, Serialize}; +use std::collections::BTreeMap; + +#[derive(Serialize, Deserialize, Clone, Debug, PartialEq)] +#[serde(untagged)] +pub enum ParamValue { + Number(f64), + String(String), + Bool(bool), +} + +pub type ParamValues = BTreeMap<String, ParamValue>; diff --git a/rust/crates/scene/src/project.rs b/rust/crates/scene/src/project.rs new file mode 100644 index 00000000..6af6c3cc --- /dev/null +++ b/rust/crates/scene/src/project.rs @@ -0,0 +1,76 @@ +use chrono::{DateTime, Utc}; +use serde::{Deserialize, Serialize}; +use time::{FrameRate, MediaTime}; + +use crate::iso_date; +use crate::scene::Scene; + +#[derive(Serialize, Deserialize, Clone, Debug, PartialEq)] +#[serde(tag = "type", rename_all = "camelCase", rename_all_fields = "camelCase")] +pub enum Background { + Color { color: String }, + Blur { blur_intensity: f64 }, +} + +#[derive(Serialize, Deserialize, Clone, Copy, Debug, Eq, PartialEq)] +#[serde(rename_all = "camelCase")] +pub struct CanvasSize { + pub width: u32, + pub height: u32, +} + +#[derive(Serialize, Deserialize, Clone, Copy, Debug, Eq, PartialEq)] +#[serde(rename_all = "camelCase")] +pub enum CanvasSizeMode { + Preset, + Custom, +} + +#[derive(Serialize, Deserialize, Clone, Debug, PartialEq)] +#[serde(rename_all = "camelCase")] +pub struct ProjectMetadata { + pub id: String, + pub name: String, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub thumbnail: Option<String>, + pub duration: MediaTime, + #[serde(with = "iso_date")] + pub created_at: DateTime<Utc>, + #[serde(with = "iso_date")] + pub updated_at: DateTime<Utc>, +} + +#[derive(Serialize, Deserialize, Clone, Debug, PartialEq)] +#[serde(rename_all = "camelCase")] +pub struct ProjectSettings { + pub fps: FrameRate, + pub canvas_size: CanvasSize, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub canvas_size_mode: Option<CanvasSizeMode>, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub last_custom_canvas_size: Option<CanvasSize>, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub original_canvas_size: Option<CanvasSize>, + pub background: Background, +} + +#[derive(Serialize, Deserialize, Clone, Debug, PartialEq)] +#[serde(rename_all = "camelCase")] +pub struct TimelineViewState { + pub zoom_level: f64, + pub scroll_left: f64, + pub playhead_time: MediaTime, +} + +#[derive(Serialize, Deserialize, Clone, Debug, PartialEq)] +#[serde(rename_all = "camelCase")] +pub struct Project { + pub metadata: ProjectMetadata, + #[serde(default)] + pub scenes: Vec<Scene>, + pub current_scene_id: String, + pub settings: ProjectSettings, + pub version: u32, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub timeline_view_state: Option<TimelineViewState>, +} diff --git a/rust/crates/scene/src/scene.rs b/rust/crates/scene/src/scene.rs new file mode 100644 index 00000000..ce59bce7 --- /dev/null +++ b/rust/crates/scene/src/scene.rs @@ -0,0 +1,33 @@ +use chrono::{DateTime, Utc}; +use serde::{Deserialize, Serialize}; +use time::MediaTime; + +use crate::iso_date; +use crate::track::SceneTracks; + +#[derive(Serialize, Deserialize, Clone, Debug, PartialEq)] +#[serde(rename_all = "camelCase")] +pub struct Bookmark { + pub time: MediaTime, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub note: Option<String>, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub color: Option<String>, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub duration: Option<MediaTime>, +} + +#[derive(Serialize, Deserialize, Clone, Debug, PartialEq)] +#[serde(rename_all = "camelCase")] +pub struct Scene { + pub id: String, + pub name: String, + pub is_main: bool, + pub tracks: SceneTracks, + #[serde(default)] + pub bookmarks: Vec<Bookmark>, + #[serde(with = "iso_date")] + pub created_at: DateTime<Utc>, + #[serde(with = "iso_date")] + pub updated_at: DateTime<Utc>, +} diff --git a/rust/crates/scene/src/track.rs b/rust/crates/scene/src/track.rs new file mode 100644 index 00000000..b35a7b25 --- /dev/null +++ b/rust/crates/scene/src/track.rs @@ -0,0 +1,88 @@ +use serde::{Deserialize, Serialize}; + +use crate::element::Element; + +#[derive(Serialize, Deserialize, Clone, Debug, PartialEq)] +#[serde(tag = "type", rename_all = "camelCase")] +pub enum Track { + Video { + id: String, + name: String, + elements: Vec<Element>, + muted: bool, + hidden: bool, + }, + Text { + id: String, + name: String, + elements: Vec<Element>, + hidden: bool, + }, + Audio { + id: String, + name: String, + elements: Vec<Element>, + muted: bool, + }, + Graphic { + id: String, + name: String, + elements: Vec<Element>, + hidden: bool, + }, + Effect { + id: String, + name: String, + elements: Vec<Element>, + hidden: bool, + }, +} + +impl Track { + pub fn id(&self) -> &str { + match self { + Track::Video { id, .. } + | Track::Text { id, .. } + | Track::Audio { id, .. } + | Track::Graphic { id, .. } + | Track::Effect { id, .. } => id, + } + } + + pub fn name(&self) -> &str { + match self { + Track::Video { name, .. } + | Track::Text { name, .. } + | Track::Audio { name, .. } + | Track::Graphic { name, .. } + | Track::Effect { name, .. } => name, + } + } + + pub fn elements(&self) -> &[Element] { + match self { + Track::Video { elements, .. } + | Track::Text { elements, .. } + | Track::Audio { elements, .. } + | Track::Graphic { elements, .. } + | Track::Effect { elements, .. } => elements, + } + } + + pub fn elements_mut(&mut self) -> &mut Vec<Element> { + match self { + Track::Video { elements, .. } + | Track::Text { elements, .. } + | Track::Audio { elements, .. } + | Track::Graphic { elements, .. } + | Track::Effect { elements, .. } => elements, + } + } +} + +#[derive(Serialize, Deserialize, Clone, Debug, PartialEq)] +pub struct SceneTracks { + pub overlay: Vec<Track>, + pub main: Track, + pub audio: Vec<Track>, +} diff --git a/rust/crates/scene/tests/round_trip.rs b/rust/crates/scene/tests/round_trip.rs new file mode 100644 index 00000000..8e26c1f7 --- /dev/null +++ b/rust/crates/scene/tests/round_trip.rs @@ -0,0 +1,190 @@ +#![recursion_limit = "256"] + +use ::scene::*; + +fn sample_project_json() -> serde_json::Value { + serde_json::json!({ + "metadata": { + "id": "proj-1", + "name": "Demo", + "duration": 10000, + "createdAt": "2026-07-28T10:00:00.000Z", + "updatedAt": "2026-07-28T11:30:00.000Z" + }, + "scenes": [{ + "id": "scene-1", + "name": "Main scene", + "isMain": true, + "tracks": { + "overlay": [ + { + "type": "text", + "id": "track-text", + "name": "Text", + "hidden": false, + "elements": [{ + "type": "text", + "id": "el-text", + "name": "Title", + "duration": 2000, + "startTime": 0, + "trimStart": 0, + "trimEnd": 0, + "params": { "content": "Hello", "fontSize": 48, "bold": true }, + "animations": { + "opacity": { + "keys": [ + { "id": "k1", "time": 0, "value": 0, "segmentToNext": "linear", "tangentMode": "auto" }, + { "id": "k2", "time": 500, "value": 1, "leftHandle": { "dt": -100, "dv": -0.1 }, "rightHandle": { "dt": 100, "dv": 0.1 }, "segmentToNext": "bezier", "tangentMode": "aligned" } + ], + "extrapolation": { "before": "hold", "after": "linear" } + }, + "color": { + "r": { "keys": [{ "id": "k3", "time": 0, "value": 0.5, "segmentToNext": "step", "tangentMode": "flat" }] }, + "g": { "keys": [{ "id": "k4", "time": 0, "value": 0.2, "segmentToNext": "linear", "tangentMode": "auto" }] } + } + }, + "effects": [{ "id": "fx1", "type": "blur", "params": { "radius": 4 }, "enabled": true }] + }] + } + ], + "main": { + "type": "video", + "id": "track-main", + "name": "Main", + "muted": false, + "hidden": false, + "elements": [ + { + "type": "video", + "id": "el-video", + "name": "Clip", + "duration": 5000, + "startTime": 0, + "trimStart": 100, + "trimEnd": 200, + "sourceDuration": 6000, + "mediaId": "media-1", + "isSourceAudioEnabled": true, + "retime": { "rate": 1.5, "maintainPitch": true }, + "params": {}, + "masks": [ + { "id": "m1", "type": "rectangle", "params": { "feather": 2, "inverted": false, "strokeColor": "#fff", "strokeWidth": 1, "strokeAlign": "inside", "centerX": 0, "centerY": 0, "width": 100, "height": 50, "rotation": 0, "scale": 1 } }, + { "id": "m2", "type": "freeform", "params": { "feather": 0, "inverted": true, "strokeColor": "#000", "strokeWidth": 0, "strokeAlign": "center", "path": [{ "id": "p1", "x": 0, "y": 0, "inX": -1, "inY": 0, "outX": 1, "outY": 0 }], "closed": true, "centerX": 10, "centerY": 20, "rotation": 15, "scale": 2 } }, + { "id": "m3", "type": "cinematic-bars", "params": { "feather": 0, "inverted": false, "strokeColor": "#000", "strokeWidth": 0, "strokeAlign": "outside", "centerX": 0, "centerY": 0, "width": 1920, "height": 200, "rotation": 0, "scale": 1 } } + ] + }, + { + "type": "image", + "id": "el-image", + "name": "Still", + "duration": 3000, + "startTime": 5000, + "trimStart": 0, + "trimEnd": 0, + "mediaId": "media-2", + "hidden": false, + "params": {} + } + ] + }, + "audio": [ + { + "type": "audio", + "id": "track-audio", + "name": "Audio", + "muted": false, + "elements": [ + { + "type": "audio", + "sourceType": "upload", + "id": "el-au1", + "name": "Music", + "duration": 8000, + "startTime": 0, + "trimStart": 0, + "trimEnd": 0, + "mediaId": "media-3", + "retime": { "rate": 1 }, + "params": { "volume": 0.8 } + }, + { + "type": "audio", + "sourceType": "library", + "id": "el-au2", + "name": "Sfx", + "duration": 1000, + "startTime": 2000, + "trimStart": 0, + "trimEnd": 0, + "sourceUrl": "https://example.com/sfx.mp3", + "params": {} + } + ] + } + ] + }, + "bookmarks": [{ "time": 1500, "note": "cut here", "color": "red", "duration": 250 }], + "createdAt": "2026-07-28T10:00:00.000Z", + "updatedAt": "2026-07-28T11:00:00.000Z" + }], + "currentSceneId": "scene-1", + "settings": { + "fps": { "numerator": 30, "denominator": 1 }, + "canvasSize": { "width": 1920, "height": 1080 }, + "canvasSizeMode": "preset", + "background": { "type": "blur", "blurIntensity": 8 } + }, + "version": 7, + "timelineViewState": { "zoomLevel": 2, "scrollLeft": 120, "playheadTime": 750 } + }) +} + +fn diff_path(a: &serde_json::Value, b: &serde_json::Value, path: &str) { + match (a, b) { + (serde_json::Value::Object(x), serde_json::Value::Object(y)) => { + for key in x.keys().chain(y.keys()) { + if x.get(key) != y.get(key) { + diff_path( + x.get(key).unwrap_or(&serde_json::Value::Null), + y.get(key).unwrap_or(&serde_json::Value::Null), + &format!("{path}.{key}"), + ); + return; + } + } + } + (serde_json::Value::Array(x), serde_json::Value::Array(y)) => { + for (i, (u, v)) in x.iter().zip(y.iter()).enumerate() { + if u != v { + diff_path(u, v, &format!("{path}[{i}]")); + return; + } + } + if x.len() != y.len() { + panic!("length mismatch at {path}: {} vs {}", x.len(), y.len()); + } + } + (serde_json::Value::Number(x), serde_json::Value::Number(y)) + if x.as_f64() == y.as_f64() => {} + _ => panic!("mismatch at {path}:\n serialized: {a}\n original: {b}"), + } +} + +#[test] +fn project_round_trip_preserves_json() { + let original = sample_project_json(); + let project: Project = serde_json::from_value(original.clone()).expect("deserialize"); + let serialized = serde_json::to_value(&project).expect("serialize"); + diff_path(&serialized, &original, "$"); +} + +#[test] +fn element_accessors_reach_base() { + let original = sample_project_json(); + let project: Project = serde_json::from_value(original).expect("deserialize"); + let main = &project.scenes[0].tracks.main; + let video = &main.elements()[0]; + assert_eq!(video.base().id, "el-video"); + assert_eq!(video.base().trim_start, time::MediaTime::from_ticks(100)); +} diff --git a/rust/crates/storage/Cargo.toml b/rust/crates/storage/Cargo.toml new file mode 100644 index 00000000..2ac5f67a --- /dev/null +++ b/rust/crates/storage/Cargo.toml @@ -0,0 +1,16 @@ +[package] +name = "storage" +version = "0.1.0" +edition = "2024" + +[dependencies] +scene = { version = "0.1.0", path = "../scene" } +transfer = { version = "0.1.0", path = "../transfer" } +serde = { version = "1.0", features = ["derive"] } +serde_json = "1.0" +thiserror = "2" + +[dev-dependencies] +tempfile = "3" +chrono = { version = "0.4", features = ["serde"] } +time = { version = "0.1.0", path = "../time" } diff --git a/rust/crates/storage/src/lib.rs b/rust/crates/storage/src/lib.rs new file mode 100644 index 00000000..981f3ad1 --- /dev/null +++ b/rust/crates/storage/src/lib.rs @@ -0,0 +1,256 @@ +use std::fs; +use std::io::ErrorKind; +use std::path::{Path, PathBuf}; + +use scene::{Project, ProjectMetadata}; +use transfer::{ + ArchiveMediaInput, ArchivedMediaMetadata, MEDIA_ENTRY_PREFIX, ProjectArchiveError, +}; + +const PROJECT_FILE: &str = "project.json"; +const MEDIA_DIR: &str = "media"; +const MANIFEST_FILE: &str = "manifest.json"; + +#[derive(Debug, thiserror::Error)] +pub enum StorageError { + #[error("project not found: {0}")] + ProjectNotFound(String), + #[error("project already exists: {0}")] + ProjectExists(String), + #[error("media not found: {0}")] + MediaNotFound(String), + #[error(transparent)] + Io(#[from] std::io::Error), + #[error(transparent)] + Json(#[from] serde_json::Error), + #[error(transparent)] + Archive(#[from] ProjectArchiveError), +} + +pub type Result<T, E = StorageError> = std::result::Result<T, E>; + +pub struct ProjectStore { + root: PathBuf, +} + +impl ProjectStore { + pub fn new(root: PathBuf) -> Self { + Self { root } + } + + pub fn root(&self) -> &Path { + &self.root + } + + pub fn project_dir(&self, project_id: &str) -> PathBuf { + self.root.join(project_id) + } + + pub fn create_project(&self, project: &Project) -> Result<()> { + let dir = self.project_dir(&project.metadata.id); + if dir.exists() { + return Err(StorageError::ProjectExists(project.metadata.id.clone())); + } + fs::create_dir_all(dir.join(MEDIA_DIR))?; + self.save_project(project) + } + + pub fn save_project(&self, project: &Project) -> Result<()> { + let dir = self.project_dir(&project.metadata.id); + fs::create_dir_all(dir.join(MEDIA_DIR))?; + let json = serde_json::to_vec_pretty(project)?; + write_atomic(&dir.join(PROJECT_FILE), &json) + } + + pub fn load_project(&self, project_id: &str) -> Result<Project> { + let path = self.project_dir(project_id).join(PROJECT_FILE); + let bytes = fs::read(&path).map_err(|error| match error.kind() { + ErrorKind::NotFound => StorageError::ProjectNotFound(project_id.to_string()), + _ => StorageError::Io(error), + })?; + Ok(serde_json::from_slice(&bytes)?) + } + + pub fn delete_project(&self, project_id: &str) -> Result<()> { + let dir = self.project_dir(project_id); + fs::remove_dir_all(&dir).map_err(|error| match error.kind() { + ErrorKind::NotFound => StorageError::ProjectNotFound(project_id.to_string()), + _ => StorageError::Io(error), + }) + } + + pub fn list_projects(&self) -> Result<Vec<ProjectMetadata>> { + fs::create_dir_all(&self.root)?; + let mut metadata = Vec::new(); + for entry in fs::read_dir(&self.root)? { + let entry = entry?; + if !entry.file_type()?.is_dir() { + continue; + } + let path = entry.path().join(PROJECT_FILE); + match fs::read(&path) { + Ok(bytes) => { + if let Ok(project) = serde_json::from_slice::<Project>(&bytes) { + metadata.push(project.metadata); + } + } + Err(error) if error.kind() == ErrorKind::NotFound => continue, + Err(error) => return Err(error.into()), + } + } + metadata.sort_by(|a, b| b.updated_at.cmp(&a.updated_at)); + Ok(metadata) + } + + pub fn add_media( + &self, + project_id: &str, + metadata: &ArchivedMediaMetadata, + data: &[u8], + ) -> Result<()> { + let media_dir = self.media_dir(project_id)?; + let mut manifest = self.read_manifest(project_id)?; + if let Some(existing) = manifest.iter().find(|item| item.id == metadata.id) { + let previous = media_dir.join(media_file_name(existing)); + let next = media_dir.join(media_file_name(metadata)); + if previous != next { + let _ = fs::remove_file(previous); + } + manifest.retain(|item| item.id != metadata.id); + } + write_atomic(&media_dir.join(media_file_name(metadata)), data)?; + manifest.push(metadata.clone()); + self.write_manifest(project_id, &manifest) + } + + pub fn media_path(&self, project_id: &str, media_id: &str) -> Result<PathBuf> { + let media_dir = self.media_dir(project_id)?; + let manifest = self.read_manifest(project_id)?; + let metadata = manifest + .iter() + .find(|item| item.id == media_id) + .ok_or_else(|| StorageError::MediaNotFound(media_id.to_string()))?; + Ok(media_dir.join(media_file_name(metadata))) + } + + pub fn delete_media(&self, project_id: &str, media_id: &str) -> Result<()> { + let media_dir = self.media_dir(project_id)?; + let mut manifest = self.read_manifest(project_id)?; + let Some(position) = manifest.iter().position(|item| item.id == media_id) else { + return Err(StorageError::MediaNotFound(media_id.to_string())); + }; + let metadata = manifest.remove(position); + let path = media_dir.join(media_file_name(&metadata)); + match fs::remove_file(&path) { + Ok(()) => {} + Err(error) if error.kind() == ErrorKind::NotFound => {} + Err(error) => return Err(error.into()), + } + self.write_manifest(project_id, &manifest) + } + + pub fn list_media(&self, project_id: &str) -> Result<Vec<ArchivedMediaMetadata>> { + self.media_dir(project_id)?; + self.read_manifest(project_id) + } + + pub fn export_ocp(&self, project_id: &str) -> Result<Vec<u8>> { + let project = self.load_project(project_id)?; + let project_json = serde_json::to_string(&project)?; + let media_dir = self.media_dir(project_id)?; + let manifest = self.read_manifest(project_id)?; + let mut media = Vec::with_capacity(manifest.len()); + for metadata in manifest { + let data = fs::read(media_dir.join(media_file_name(&metadata)))?; + media.push(ArchiveMediaInput { metadata, data }); + } + Ok(transfer::build_project_archive(&project_json, media)?) + } + + pub fn import_ocp(&self, bytes: &[u8]) -> Result<String> { + let parsed = transfer::parse_project_archive(bytes)?; + let mut project: Project = + serde_json::from_value(serde_json::Value::Object(parsed.project.clone()))?; + + let base_id = project.metadata.id.clone(); + let mut candidate = base_id.clone(); + let mut suffix = 1; + while self.project_dir(&candidate).exists() { + candidate = format!("{base_id}-{suffix}"); + suffix += 1; + } + project.metadata.id = candidate.clone(); + + let import = (|| -> Result<()> { + self.create_project(&project)?; + let media_dir = self.media_dir(&candidate)?; + let mut manifest = Vec::with_capacity(parsed.media.len()); + for entry in &parsed.media { + let data = parsed.read_media_data(&entry.entry)?; + let file_name = media_file_name(&entry.metadata); + write_atomic(&media_dir.join(file_name), data)?; + manifest.push(entry.metadata.clone()); + } + self.write_manifest(&candidate, &manifest) + })(); + + if let Err(error) = import { + let _ = fs::remove_dir_all(self.project_dir(&candidate)); + return Err(error); + } + Ok(candidate) + } + + fn media_dir(&self, project_id: &str) -> Result<PathBuf> { + let dir = self.project_dir(project_id); + if !dir.is_dir() { + return Err(StorageError::ProjectNotFound(project_id.to_string())); + } + Ok(dir.join(MEDIA_DIR)) + } + + fn read_manifest(&self, project_id: &str) -> Result<Vec<ArchivedMediaMetadata>> { + let path = self + .project_dir(project_id) + .join(MEDIA_DIR) + .join(MANIFEST_FILE); + match fs::read(&path) { + Ok(bytes) => Ok(serde_json::from_slice(&bytes)?), + Err(error) if error.kind() == ErrorKind::NotFound => Ok(Vec::new()), + Err(error) => Err(error.into()), + } + } + + fn write_manifest( + &self, + project_id: &str, + manifest: &[ArchivedMediaMetadata], + ) -> Result<()> { + let json = serde_json::to_vec_pretty(manifest)?; + write_atomic( + &self + .project_dir(project_id) + .join(MEDIA_DIR) + .join(MANIFEST_FILE), + &json, + ) + } +} + +fn media_file_name(metadata: &ArchivedMediaMetadata) -> String { + transfer::build_media_entry_name(&metadata.id, &metadata.name) + .trim_start_matches(MEDIA_ENTRY_PREFIX) + .to_string() +} + +fn write_atomic(path: &Path, data: &[u8]) -> Result<()> { + let mut tmp_name = path + .file_name() + .map(std::ffi::OsString::from) + .unwrap_or_else(|| std::ffi::OsString::from("tmp")); + tmp_name.push(".tmp"); + let tmp = path.with_file_name(tmp_name); + fs::write(&tmp, data)?; + fs::rename(&tmp, path)?; + Ok(()) +} diff --git a/rust/crates/storage/tests/store.rs b/rust/crates/storage/tests/store.rs new file mode 100644 index 00000000..696ddde8 --- /dev/null +++ b/rust/crates/storage/tests/store.rs @@ -0,0 +1,276 @@ +use chrono::{DateTime, Utc}; +use scene::{ + Background, BaseTimelineElement, CanvasSize, Element, Project, ProjectMetadata, + ProjectSettings, Scene, SceneTracks, Track, VideoElement, +}; +use storage::ProjectStore; +use tempfile::TempDir; +use time::{FrameRate, MediaTime}; +use transfer::ArchivedMediaMetadata; + +fn date(day: u8) -> DateTime<Utc> { + DateTime::parse_from_rfc3339(&format!("2026-01-{day:02}T00:00:00Z")) + .unwrap() + .with_timezone(&Utc) +} + +fn seconds(value: f64) -> MediaTime { + MediaTime::from_seconds_f64(value).unwrap() +} + +fn sample_project(id: &str) -> Project { + let scene_id = format!("{id}-scene"); + let element = Element::Video(VideoElement { + base: BaseTimelineElement { + id: "element-1".to_string(), + name: "Clip".to_string(), + duration: seconds(5.0), + start_time: MediaTime::ZERO, + trim_start: MediaTime::ZERO, + trim_end: MediaTime::ZERO, + source_duration: Some(seconds(10.0)), + animations: None, + params: Default::default(), + }, + media_id: "media-1".to_string(), + is_source_audio_enabled: None, + hidden: None, + retime: None, + effects: None, + masks: None, + }); + let scene = Scene { + id: scene_id.clone(), + name: "Main scene".to_string(), + is_main: true, + tracks: SceneTracks { + overlay: vec![], + main: Track::Video { + id: "track-1".to_string(), + name: "Main".to_string(), + elements: vec![element], + muted: false, + hidden: false, + }, + audio: vec![], + }, + bookmarks: vec![], + created_at: date(1), + updated_at: date(1), + }; + Project { + metadata: ProjectMetadata { + id: id.to_string(), + name: "Sample Project".to_string(), + thumbnail: None, + duration: seconds(5.0), + created_at: date(1), + updated_at: date(2), + }, + scenes: vec![scene], + current_scene_id: scene_id, + settings: ProjectSettings { + fps: FrameRate::FPS_30, + 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: 14, + timeline_view_state: None, + } +} + +fn sample_media(id: &str, name: &str) -> ArchivedMediaMetadata { + ArchivedMediaMetadata { + id: id.to_string(), + name: name.to_string(), + media_type: "video".to_string(), + file_type: "video/mp4".to_string(), + last_modified: 1_700_000_000_000.0, + width: Some(1920.0), + height: Some(1080.0), + duration: Some(5.0), + ephemeral: None, + thumbnail_url: None, + } +} + +fn store() -> (TempDir, ProjectStore) { + let dir = TempDir::new().unwrap(); + let store = ProjectStore::new(dir.path().join("projects")); + (dir, store) +} + +#[test] +fn project_round_trip() { + let (_dir, store) = store(); + let project = sample_project("project-1"); + + store.create_project(&project).unwrap(); + assert!(store.create_project(&project).is_err()); + + let mut updated = project.clone(); + updated.metadata.name = "Renamed Project".to_string(); + store.save_project(&updated).unwrap(); + + let listed = store.list_projects().unwrap(); + assert_eq!(listed, vec![updated.metadata.clone()]); + + let loaded = store.load_project("project-1").unwrap(); + assert_eq!(loaded, updated); + + let raw = std::fs::read_to_string( + store.project_dir("project-1").join("project.json"), + ) + .unwrap(); + assert!(raw.contains("\"currentSceneId\"")); + assert!(raw.contains("\"createdAt\"")); +} + +#[test] +fn list_projects_sorts_by_updated_descending() { + let (_dir, store) = store(); + store.create_project(&sample_project("project-old")).unwrap(); + let mut newer = sample_project("project-new"); + newer.metadata.updated_at = date(20); + store.create_project(&newer).unwrap(); + + let listed = store.list_projects().unwrap(); + let ids: Vec<&str> = listed.iter().map(|meta| meta.id.as_str()).collect(); + assert_eq!(ids, vec!["project-new", "project-old"]); +} + +#[test] +fn media_round_trip() { + let (_dir, store) = store(); + let project = sample_project("project-1"); + store.create_project(&project).unwrap(); + + let metadata = sample_media("media-1", "my clip.mp4"); + let bytes = b"fake video bytes"; + store.add_media("project-1", &metadata, bytes).unwrap(); + + let listed = store.list_media("project-1").unwrap(); + assert_eq!(listed, vec![metadata.clone()]); + + let path = store.media_path("project-1", "media-1").unwrap(); + assert_eq!( + path.file_name().unwrap().to_str().unwrap(), + "media-1-my_clip.mp4" + ); + assert_eq!(std::fs::read(&path).unwrap(), bytes); + + let manifest = std::fs::read_to_string( + store + .project_dir("project-1") + .join("media") + .join("manifest.json"), + ) + .unwrap(); + let parsed: Vec<ArchivedMediaMetadata> = serde_json::from_str(&manifest).unwrap(); + assert_eq!(parsed, vec![metadata.clone()]); + assert!(!parsed[0].media_type.is_empty()); + + store.delete_media("project-1", "media-1").unwrap(); + assert!(store.list_media("project-1").unwrap().is_empty()); + assert!(store.media_path("project-1", "media-1").is_err()); + assert!(!path.exists()); +} + +#[test] +fn add_media_replaces_existing_id() { + let (_dir, store) = store(); + let project = sample_project("project-1"); + store.create_project(&project).unwrap(); + + let first = sample_media("media-1", "first.mp4"); + store.add_media("project-1", &first, b"first").unwrap(); + let second = sample_media("media-1", "second.mp4"); + store.add_media("project-1", &second, b"second").unwrap(); + + let listed = store.list_media("project-1").unwrap(); + assert_eq!(listed, vec![second]); + let path = store.media_path("project-1", "media-1").unwrap(); + assert_eq!(std::fs::read(&path).unwrap(), b"second"); + let media_dir = store.project_dir("project-1").join("media"); + let files: Vec<_> = std::fs::read_dir(&media_dir) + .unwrap() + .map(|entry| entry.unwrap().file_name()) + .collect(); + assert_eq!(files.len(), 2); +} + +#[test] +fn export_import_round_trip() { + let (_dir_a, source) = store(); + let project = sample_project("project-1"); + source.create_project(&project).unwrap(); + let metadata = sample_media("media-1", "my clip.mp4"); + let bytes = b"fake video bytes"; + source.add_media("project-1", &metadata, bytes).unwrap(); + + let archive = source.export_ocp("project-1").unwrap(); + + let (_dir_b, target) = store(); + let imported_id = target.import_ocp(&archive).unwrap(); + assert_eq!(imported_id, "project-1"); + + let loaded = target.load_project(&imported_id).unwrap(); + assert_eq!(loaded, project); + + let listed = target.list_media(&imported_id).unwrap(); + assert_eq!(listed, vec![metadata]); + let path = target.media_path(&imported_id, "media-1").unwrap(); + assert_eq!(std::fs::read(&path).unwrap(), bytes); +} + +#[test] +fn delete_project_removes_directory() { + let (_dir, store) = store(); + let project = sample_project("project-1"); + store.create_project(&project).unwrap(); + store + .add_media("project-1", &sample_media("media-1", "clip.mp4"), b"data") + .unwrap(); + + let project_dir = store.project_dir("project-1"); + assert!(project_dir.is_dir()); + + store.delete_project("project-1").unwrap(); + assert!(!project_dir.exists()); + assert!(store.load_project("project-1").is_err()); + assert!(store.list_projects().unwrap().is_empty()); + assert!(store.delete_project("project-1").is_err()); +} + +#[test] +fn import_id_collision_gets_new_id() { + let (_dir_a, source) = store(); + let project = sample_project("project-1"); + source.create_project(&project).unwrap(); + let archive = source.export_ocp("project-1").unwrap(); + + let (_dir_b, target) = store(); + let first = target.import_ocp(&archive).unwrap(); + let second = target.import_ocp(&archive).unwrap(); + + assert_eq!(first, "project-1"); + assert_ne!(first, second); + assert_eq!(second, "project-1-1"); + + let first_project = target.load_project(&first).unwrap(); + let second_project = target.load_project(&second).unwrap(); + assert_eq!(first_project.metadata.id, first); + assert_eq!(second_project.metadata.id, second); + assert_eq!(second_project.scenes, first_project.scenes); + + let listed = target.list_projects().unwrap(); + assert_eq!(listed.len(), 2); +} diff --git a/rust/crates/text/Cargo.toml b/rust/crates/text/Cargo.toml new file mode 100644 index 00000000..ae4abe1e --- /dev/null +++ b/rust/crates/text/Cargo.toml @@ -0,0 +1,9 @@ +[package] +name = "text" +version = "0.1.0" +edition = "2024" + +[dependencies] +scene = { version = "0.1.0", path = "../scene" } +cosmic-text = "0.15" +thiserror = "2" diff --git a/rust/crates/text/src/lib.rs b/rust/crates/text/src/lib.rs new file mode 100644 index 00000000..8b137891 --- /dev/null +++ b/rust/crates/text/src/lib.rs @@ -0,0 +1 @@ + diff --git a/rust/crates/timeline/Cargo.toml b/rust/crates/timeline/Cargo.toml new file mode 100644 index 00000000..30df07bd --- /dev/null +++ b/rust/crates/timeline/Cargo.toml @@ -0,0 +1,12 @@ +[package] +name = "timeline" +version = "0.1.0" +edition = "2024" + +[dependencies] +animation = { version = "0.1.0", path = "../animation" } +chrono = { version = "0.4", features = ["serde"] } +scene = { version = "0.1.0", path = "../scene" } +serde = { version = "1.0", features = ["derive"] } +serde_json = "1.0" +time = { version = "0.1.0", path = "../time" } diff --git a/rust/crates/timeline/src/audio_separation.rs b/rust/crates/timeline/src/audio_separation.rs new file mode 100644 index 00000000..67494380 --- /dev/null +++ b/rust/crates/timeline/src/audio_separation.rs @@ -0,0 +1,97 @@ +use scene::{Element, ElementAnimations, ParamValue, ParamValues, UploadAudioElement, VideoElement}; + +use crate::defaults::DEFAULT_VOLUME; + +#[derive(Clone, Copy, Debug, PartialEq)] +pub struct MediaAudioState { + pub has_audio: Option<bool>, +} + +pub fn is_source_audio_enabled(element: &VideoElement) -> bool { + element.is_source_audio_enabled != Some(false) +} + +pub fn is_source_audio_separated(element: &VideoElement) -> bool { + !is_source_audio_enabled(element) +} + +pub fn can_extract_source_audio(element: &Element, media_asset: Option<&MediaAudioState>) -> bool { + let Element::Video(video) = element else { + return false; + }; + is_source_audio_enabled(video) && media_asset.is_some_and(|media| media.has_audio != Some(false)) +} + +pub fn can_recover_source_audio(element: &Element) -> bool { + let Element::Video(video) = element else { + return false; + }; + is_source_audio_separated(video) +} + +pub fn can_toggle_source_audio(element: &Element, media_asset: Option<&MediaAudioState>) -> bool { + can_recover_source_audio(element) || can_extract_source_audio(element, media_asset) +} + +pub fn does_element_have_enabled_audio( + element: &Element, + media_asset: Option<&MediaAudioState>, +) -> bool { + match element { + Element::Audio(_) => true, + Element::Video(video) => { + media_asset.is_some_and(|media| media.has_audio != Some(false)) + && is_source_audio_enabled(video) + } + _ => false, + } +} + +pub fn build_separated_audio_element( + source_element: &VideoElement, + id: impl Into<String>, +) -> UploadAudioElement { + let source_base = &source_element.base; + + let mut params = ParamValues::new(); + let volume = match source_base.params.get("volume") { + Some(ParamValue::Number(volume)) => *volume, + _ => DEFAULT_VOLUME, + }; + params.insert("volume".to_string(), ParamValue::Number(volume)); + params.insert( + "muted".to_string(), + ParamValue::Bool(source_base.params.get("muted") == Some(&ParamValue::Bool(true))), + ); + + UploadAudioElement { + base: scene::BaseTimelineElement { + id: id.into(), + name: source_base.name.clone(), + duration: source_base.duration, + start_time: source_base.start_time, + trim_start: source_base.trim_start, + trim_end: source_base.trim_end, + source_duration: source_base.source_duration, + animations: clone_volume_animations(source_base.animations.as_ref()), + params, + }, + retime: source_element.retime, + media_id: source_element.media_id.clone(), + } +} + +pub fn get_source_audio_action_label(element: &VideoElement) -> &'static str { + if is_source_audio_separated(element) { + "Recover audio" + } else { + "Extract audio" + } +} + +fn clone_volume_animations(animations: Option<&ElementAnimations>) -> Option<ElementAnimations> { + let volume_data = animations?.get("volume")?; + let mut volume_only = ElementAnimations::new(); + volume_only.insert("volume".to_string(), volume_data.clone()); + animation::clone_animations(Some(&volume_only), true) +} diff --git a/rust/crates/timeline/src/creation.rs b/rust/crates/timeline/src/creation.rs new file mode 100644 index 00000000..a399d3d7 --- /dev/null +++ b/rust/crates/timeline/src/creation.rs @@ -0,0 +1,11 @@ +use time::{MediaTime, TICKS_PER_SECOND}; + +pub const DEFAULT_NEW_ELEMENT_DURATION: MediaTime = + MediaTime::from_ticks(5 * TICKS_PER_SECOND); + +pub fn to_element_duration_ticks(seconds: Option<f64>) -> Option<MediaTime> { + match seconds { + None => Some(DEFAULT_NEW_ELEMENT_DURATION), + Some(seconds) => MediaTime::from_seconds_f64(seconds), + } +} diff --git a/rust/crates/timeline/src/defaults.rs b/rust/crates/timeline/src/defaults.rs new file mode 100644 index 00000000..504bab92 --- /dev/null +++ b/rust/crates/timeline/src/defaults.rs @@ -0,0 +1,130 @@ +use scene::{ParamValue, ParamValues, TextElement}; +use time::MediaTime; + +use crate::creation::DEFAULT_NEW_ELEMENT_DURATION; + +pub const DEFAULT_OPACITY: f64 = 1.0; +pub const DEFAULT_BLEND_MODE: &str = "normal"; +pub const DEFAULT_VOLUME: f64 = 0.0; +pub const DEFAULT_TRANSFORM_SCALE: f64 = 1.0; +pub const DEFAULT_TRANSFORM_POSITION: f64 = 0.0; +pub const DEFAULT_TRANSFORM_ROTATE: f64 = 0.0; + +pub const DEFAULT_TEXT_LETTER_SPACING: f64 = 0.0; +pub const DEFAULT_TEXT_LINE_HEIGHT: f64 = 1.2; + +#[derive(Clone, Debug, PartialEq)] +pub struct DefaultTextBackground { + 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, +} + +pub fn default_text_background() -> DefaultTextBackground { + DefaultTextBackground { + enabled: false, + color: "#000000".to_string(), + corner_radius: 0.0, + padding_x: 30.0, + padding_y: 42.0, + offset_x: 0.0, + offset_y: 0.0, + } +} + +pub fn default_text_params() -> ParamValues { + let background = default_text_background(); + ParamValues::from([ + ("content".to_string(), ParamValue::String("Default text".to_string())), + ("fontSize".to_string(), ParamValue::Number(15.0)), + ("fontFamily".to_string(), ParamValue::String("Arial".to_string())), + ("color".to_string(), ParamValue::String("#ffffff".to_string())), + ("textAlign".to_string(), ParamValue::String("center".to_string())), + ("fontWeight".to_string(), ParamValue::String("normal".to_string())), + ("fontStyle".to_string(), ParamValue::String("normal".to_string())), + ("textDecoration".to_string(), ParamValue::String("none".to_string())), + ( + "letterSpacing".to_string(), + ParamValue::Number(DEFAULT_TEXT_LETTER_SPACING), + ), + ( + "lineHeight".to_string(), + ParamValue::Number(DEFAULT_TEXT_LINE_HEIGHT), + ), + ( + "background.enabled".to_string(), + ParamValue::Bool(background.enabled), + ), + ( + "background.color".to_string(), + ParamValue::String(background.color), + ), + ( + "background.cornerRadius".to_string(), + ParamValue::Number(background.corner_radius), + ), + ( + "background.paddingX".to_string(), + ParamValue::Number(background.padding_x), + ), + ( + "background.paddingY".to_string(), + ParamValue::Number(background.padding_y), + ), + ( + "background.offsetX".to_string(), + ParamValue::Number(background.offset_x), + ), + ( + "background.offsetY".to_string(), + ParamValue::Number(background.offset_y), + ), + ( + "transform.positionX".to_string(), + ParamValue::Number(DEFAULT_TRANSFORM_POSITION), + ), + ( + "transform.positionY".to_string(), + ParamValue::Number(DEFAULT_TRANSFORM_POSITION), + ), + ( + "transform.scaleX".to_string(), + ParamValue::Number(DEFAULT_TRANSFORM_SCALE), + ), + ( + "transform.scaleY".to_string(), + ParamValue::Number(DEFAULT_TRANSFORM_SCALE), + ), + ( + "transform.rotate".to_string(), + ParamValue::Number(DEFAULT_TRANSFORM_ROTATE), + ), + ("opacity".to_string(), ParamValue::Number(DEFAULT_OPACITY)), + ( + "blendMode".to_string(), + ParamValue::String(DEFAULT_BLEND_MODE.to_string()), + ), + ]) +} + +pub fn default_text_element(id: impl Into<String>) -> TextElement { + TextElement { + base: scene::BaseTimelineElement { + id: id.into(), + name: "Text".to_string(), + duration: DEFAULT_NEW_ELEMENT_DURATION, + start_time: MediaTime::ZERO, + trim_start: MediaTime::ZERO, + trim_end: MediaTime::ZERO, + source_duration: None, + animations: None, + params: default_text_params(), + }, + hidden: None, + effects: None, + } +} diff --git a/rust/crates/timeline/src/drag_utils.rs b/rust/crates/timeline/src/drag_utils.rs new file mode 100644 index 00000000..6dcc6c6f --- /dev/null +++ b/rust/crates/timeline/src/drag_utils.rs @@ -0,0 +1,14 @@ +use time::{MediaTime, TICKS_PER_SECOND}; + +use crate::snap::BASE_TIMELINE_PIXELS_PER_SECOND; + +pub fn get_mouse_time_from_client_x( + client_x: f64, + container_left: f64, + zoom_level: f64, + scroll_left: f64, +) -> MediaTime { + let mouse_x = client_x - container_left + scroll_left; + let seconds = (mouse_x / (BASE_TIMELINE_PIXELS_PER_SECOND * zoom_level)).max(0.0); + MediaTime::from_ticks((seconds * TICKS_PER_SECOND as f64).round() as i64) +} diff --git a/rust/crates/timeline/src/lib.rs b/rust/crates/timeline/src/lib.rs new file mode 100644 index 00000000..e724b5bc --- /dev/null +++ b/rust/crates/timeline/src/lib.rs @@ -0,0 +1,96 @@ +use scene::{AudioElement, Element, RetimeConfig, SceneTracks}; +use time::MediaTime; + +pub mod audio_separation; +pub mod creation; +pub mod defaults; +pub mod drag_utils; +pub mod placement; +pub mod resize; +pub mod retime; +pub mod ripple; +pub mod scenes; +pub mod snap; +pub mod split; +pub mod track_capabilities; +pub mod track_element_update; +pub mod types; +pub mod update_pipeline; + +pub use audio_separation::*; +pub use creation::*; +pub use track_capabilities::*; +pub use track_element_update::*; +pub use types::{ElementType, TrackType}; + +pub fn calculate_total_duration(tracks: &SceneTracks) -> MediaTime { + placement::ordered_tracks(tracks) + .iter() + .flat_map(|track| track.elements().iter()) + .map(|element| element.base().start_time + element.base().duration) + .fold(MediaTime::ZERO, MediaTime::max) +} + +pub fn is_visual_element(element: &Element) -> bool { + matches!( + element, + Element::Video(_) + | Element::Image(_) + | Element::Text(_) + | Element::Sticker(_) + | Element::Graphic(_) + ) +} + +pub fn is_maskable_element(element: &Element) -> bool { + matches!(element, Element::Video(_) | Element::Image(_) | Element::Graphic(_)) +} + +pub fn is_retimable_element(element: &Element) -> bool { + matches!(element, Element::Video(_) | Element::Audio(_)) +} + +pub fn can_element_have_audio(element: &Element) -> bool { + matches!(element, Element::Audio(_) | Element::Video(_)) +} + +pub fn can_element_be_hidden(element: &Element) -> bool { + is_visual_element(element) +} + +pub fn has_element_effects(element: &Element) -> bool { + let effects = match element { + Element::Video(video) => video.effects.as_ref(), + Element::Image(image) => image.effects.as_ref(), + Element::Text(text) => text.effects.as_ref(), + Element::Sticker(sticker) => sticker.effects.as_ref(), + Element::Graphic(graphic) => graphic.effects.as_ref(), + _ => None, + }; + effects.is_some_and(|effects| !effects.is_empty()) +} + +pub fn has_media_id(element: &Element) -> bool { + matches!( + element, + Element::Audio(AudioElement::Upload(_)) | Element::Video(_) | Element::Image(_) + ) +} + +pub fn element_retime(element: &Element) -> Option<RetimeConfig> { + match element { + Element::Video(video) => video.retime, + Element::Audio(AudioElement::Upload(audio)) => audio.retime, + Element::Audio(AudioElement::Library(audio)) => audio.retime, + _ => None, + } +} + +pub fn set_element_retime(element: &mut Element, retime: Option<RetimeConfig>) { + match element { + Element::Video(video) => video.retime = retime, + Element::Audio(AudioElement::Upload(audio)) => audio.retime = retime, + Element::Audio(AudioElement::Library(audio)) => audio.retime = retime, + _ => {} + } +} diff --git a/rust/crates/timeline/src/placement/apply.rs b/rust/crates/timeline/src/placement/apply.rs new file mode 100644 index 00000000..4e044a68 --- /dev/null +++ b/rust/crates/timeline/src/placement/apply.rs @@ -0,0 +1,101 @@ +use scene::{Element, SceneTracks, Track}; + +use super::track_factory::build_empty_track; +use super::{PlacementResult, ordered_tracks}; +use crate::track_element_update::update_track_in_scene_tracks; +use crate::types::TrackType; + +pub struct PlacementApplied { + pub updated_tracks: SceneTracks, + pub target_track_id: String, +} + +pub struct ApplyPlacementParams { + pub tracks: SceneTracks, + pub placement_result: PlacementResult, + pub elements: Vec<Element>, + pub new_track_id: String, + pub new_track_insert_index_override: Option<usize>, +} + +pub fn apply_placement(params: &ApplyPlacementParams) -> Option<PlacementApplied> { + let tracks = ¶ms.tracks; + match ¶ms.placement_result { + PlacementResult::ExistingTrack { track_index, .. } => { + let ordered = ordered_tracks(tracks); + let target_track = ordered.get(*track_index)?; + + let updated_tracks = + update_track_in_scene_tracks(tracks, target_track.id(), |track| { + let mut next = track.clone(); + next.elements_mut().extend(params.elements.iter().cloned()); + next + }); + + Some(PlacementApplied { + updated_tracks, + target_track_id: target_track.id().to_string(), + }) + } + PlacementResult::NewTrack { + track_type, + insert_index, + .. + } => { + let new_track_id = params.new_track_id.clone(); + 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 updated_tracks = if *track_type == TrackType::Audio { + SceneTracks { + audio: insert_into_audio_tracks(tracks, insert_index, new_track), + ..tracks.clone() + } + } else { + SceneTracks { + overlay: insert_into_overlay_tracks(tracks, insert_index, new_track), + ..tracks.clone() + } + }; + + Some(PlacementApplied { + updated_tracks, + target_track_id: new_track_id, + }) + } + } +} + +fn insert_into_overlay_tracks( + tracks: &SceneTracks, + insert_index: usize, + track: Track, +) -> Vec<Track> { + let normalized = insert_index.min(tracks.overlay.len()); + let mut next = tracks.overlay.clone(); + next.insert(normalized, track); + next +} + +fn insert_into_audio_tracks(tracks: &SceneTracks, insert_index: usize, track: Track) -> Vec<Track> { + let audio_index = insert_index + .saturating_sub(tracks.overlay.len() + 1) + .min(tracks.audio.len()); + let mut next = tracks.audio.clone(); + next.insert(audio_index, track); + 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/compatibility.rs b/rust/crates/timeline/src/placement/compatibility.rs new file mode 100644 index 00000000..9512af49 --- /dev/null +++ b/rust/crates/timeline/src/placement/compatibility.rs @@ -0,0 +1,43 @@ +use crate::types::{ElementType, TrackType}; + +pub fn get_track_type_for_element_type(element_type: ElementType) -> TrackType { + match element_type { + ElementType::Audio => TrackType::Audio, + ElementType::Text => TrackType::Text, + ElementType::Sticker | ElementType::Graphic => TrackType::Graphic, + ElementType::Effect => TrackType::Effect, + ElementType::Video | ElementType::Image => TrackType::Video, + } +} + +pub fn can_element_go_on_track(element_type: ElementType, track_type: TrackType) -> bool { + get_track_type_for_element_type(element_type) == track_type +} + +pub struct TrackCompatibility { + pub is_valid: bool, + pub error_message: Option<String>, +} + +pub fn validate_element_track_compatibility( + element_type: ElementType, + track_type: TrackType, +) -> TrackCompatibility { + let is_valid = can_element_go_on_track(element_type, track_type); + + if !is_valid { + return TrackCompatibility { + is_valid: false, + error_message: Some(format!( + "{} elements cannot be placed on {} tracks", + element_type.as_str(), + track_type.as_str(), + )), + }; + } + + TrackCompatibility { + is_valid: true, + error_message: None, + } +} diff --git a/rust/crates/timeline/src/placement/insert_index.rs b/rust/crates/timeline/src/placement/insert_index.rs new file mode 100644 index 00000000..b0feaec6 --- /dev/null +++ b/rust/crates/timeline/src/placement/insert_index.rs @@ -0,0 +1,81 @@ +use scene::SceneTracks; + +use super::InsertDirection; +use crate::types::TrackType; + +pub fn get_default_insert_index_for_track(tracks: &SceneTracks, track_type: TrackType) -> usize { + if track_type == TrackType::Audio { + return tracks.overlay.len() + 1 + tracks.audio.len(); + } + + if track_type == TrackType::Effect { + return 0; + } + + tracks.overlay.len() +} + +pub fn get_highest_insert_index_for_track(tracks: &SceneTracks, track_type: TrackType) -> usize { + if track_type == TrackType::Audio { + return tracks.overlay.len() + 1; + } + + 0 +} + +pub struct PreferredNewTrackPlacement { + pub insert_index: usize, + pub insert_position: Option<InsertDirection>, +} + +pub fn resolve_preferred_new_track_placement( + tracks: &SceneTracks, + track_type: TrackType, + preferred_index: usize, + direction: InsertDirection, +) -> PreferredNewTrackPlacement { + let track_count = tracks.overlay.len() + 1 + tracks.audio.len(); + if track_count == 0 { + return PreferredNewTrackPlacement { + insert_index: 0, + insert_position: (track_type == TrackType::Audio) + .then_some(InsertDirection::Below), + }; + } + + let safe_preferred_index = preferred_index.min(track_count - 1); + let main_track_index = tracks.overlay.len(); + + if track_type == TrackType::Audio { + if safe_preferred_index <= main_track_index { + return PreferredNewTrackPlacement { + insert_index: main_track_index + 1, + insert_position: Some(InsertDirection::Below), + }; + } + + return PreferredNewTrackPlacement { + insert_index: match direction { + InsertDirection::Above => safe_preferred_index, + InsertDirection::Below => safe_preferred_index + 1, + }, + insert_position: Some(direction), + }; + } + + let insert_index = match direction { + InsertDirection::Above => safe_preferred_index, + InsertDirection::Below => safe_preferred_index + 1, + }; + if insert_index > main_track_index { + return PreferredNewTrackPlacement { + insert_index: main_track_index, + insert_position: Some(InsertDirection::Above), + }; + } + + PreferredNewTrackPlacement { + insert_index, + insert_position: Some(direction), + } +} diff --git a/rust/crates/timeline/src/placement/main_track.rs b/rust/crates/timeline/src/placement/main_track.rs new file mode 100644 index 00000000..01d91d4f --- /dev/null +++ b/rust/crates/timeline/src/placement/main_track.rs @@ -0,0 +1,44 @@ +use scene::{Element, SceneTracks, Track}; +use time::MediaTime; + +pub const MAIN_TRACK_NAME: &str = "Main Track"; + +pub fn get_earliest_main_track_element<'a>( + main_track: &'a Track, + exclude_element_id: Option<&str>, +) -> Option<&'a Element> { + main_track + .elements() + .iter() + .filter(|element| exclude_element_id != Some(element.base().id.as_str())) + .reduce(|earliest, element| { + if element.base().start_time < earliest.base().start_time { + element + } else { + earliest + } + }) +} + +pub fn enforce_main_track_start( + tracks: &SceneTracks, + target_track_id: &str, + requested_start_time: MediaTime, + exclude_element_id: Option<&str>, +) -> MediaTime { + if tracks.main.id() != target_track_id { + return requested_start_time; + } + + let Some(earliest_element) = + get_earliest_main_track_element(&tracks.main, exclude_element_id) + else { + return MediaTime::ZERO; + }; + + if requested_start_time <= earliest_element.base().start_time { + return MediaTime::ZERO; + } + + requested_start_time +} diff --git a/rust/crates/timeline/src/placement/mod.rs b/rust/crates/timeline/src/placement/mod.rs new file mode 100644 index 00000000..8ad5710e --- /dev/null +++ b/rust/crates/timeline/src/placement/mod.rs @@ -0,0 +1,104 @@ +use scene::{Element, SceneTracks, Track}; +use time::MediaTime; + +use crate::types::{ElementType, TrackType}; + +pub mod apply; +pub mod compatibility; +pub mod insert_index; +pub mod main_track; +pub mod overlap; +pub mod resolve; +pub mod track_factory; + +pub use apply::{PlacementApplied, apply_placement}; +pub use compatibility::{ + can_element_go_on_track, get_track_type_for_element_type, validate_element_track_compatibility, +}; +pub use insert_index::{ + get_default_insert_index_for_track, get_highest_insert_index_for_track, + resolve_preferred_new_track_placement, +}; +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 track_factory::build_empty_track; + +#[derive(Clone, Debug, PartialEq)] +pub struct PlacementTimeSpan { + pub start_time: MediaTime, + pub duration: MediaTime, + pub exclude_element_id: Option<String>, +} + +#[derive(Clone, Copy, Debug, PartialEq)] +pub enum PlacementSubject { + ElementType(ElementType), + TrackType(TrackType), +} + +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub enum InsertDirection { + Above, + Below, +} + +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub enum VerticalDragDirection { + Up, + Down, +} + +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub enum NewTrackPosition { + Highest, + Default, +} + +#[derive(Clone, Debug, PartialEq)] +pub enum PlacementStrategy { + Explicit { + track_id: String, + }, + FirstAvailable, + PreferIndex { + track_index: usize, + hover_direction: InsertDirection, + vertical_drag_direction: Option<VerticalDragDirection>, + create_new_track_only: bool, + }, + AboveSource { + source_track_index: usize, + }, + AlwaysNew { + position: NewTrackPosition, + }, +} + +#[derive(Clone, Debug, PartialEq)] +pub enum PlacementResult { + ExistingTrack { + track_id: String, + track_index: usize, + track_type: TrackType, + adjusted_start_time: Option<MediaTime>, + }, + NewTrack { + track_type: TrackType, + insert_index: usize, + insert_position: Option<InsertDirection>, + }, +} + +pub fn ordered_tracks(tracks: &SceneTracks) -> Vec<&Track> { + tracks + .overlay + .iter() + .chain(std::iter::once(&tracks.main)) + .chain(tracks.audio.iter()) + .collect() +} + +pub fn track_elements(track: &Track) -> &[Element] { + track.elements() +} diff --git a/rust/crates/timeline/src/placement/overlap.rs b/rust/crates/timeline/src/placement/overlap.rs new file mode 100644 index 00000000..98055e9a --- /dev/null +++ b/rust/crates/timeline/src/placement/overlap.rs @@ -0,0 +1,34 @@ +use scene::Element; +use time::MediaTime; + +use super::PlacementTimeSpan; + +fn would_element_overlap( + elements: &[Element], + start_time: MediaTime, + end_time: MediaTime, + exclude_element_id: Option<&str>, +) -> bool { + elements.iter().any(|element| { + if exclude_element_id == Some(element.base().id.as_str()) { + return false; + } + + let element_end = element.base().start_time + element.base().duration; + start_time < element_end && end_time > element.base().start_time + }) +} + +pub fn can_place_time_spans_on_track( + elements: &[Element], + time_spans: &[PlacementTimeSpan], +) -> bool { + time_spans.iter().all(|span| { + !would_element_overlap( + elements, + span.start_time, + span.start_time + span.duration, + span.exclude_element_id.as_deref(), + ) + }) +} diff --git a/rust/crates/timeline/src/placement/resolve.rs b/rust/crates/timeline/src/placement/resolve.rs new file mode 100644 index 00000000..e95bc93c --- /dev/null +++ b/rust/crates/timeline/src/placement/resolve.rs @@ -0,0 +1,223 @@ +use scene::{SceneTracks, Track}; +use time::MediaTime; + +use super::compatibility::get_track_type_for_element_type; +use super::insert_index::{ + get_default_insert_index_for_track, get_highest_insert_index_for_track, + resolve_preferred_new_track_placement, +}; +use super::main_track::enforce_main_track_start; +use super::overlap::can_place_time_spans_on_track; +use super::{ + InsertDirection, NewTrackPosition, PlacementResult, PlacementStrategy, PlacementSubject, + PlacementTimeSpan, VerticalDragDirection, ordered_tracks, +}; +use crate::types::TrackType; + +pub struct ResolveTrackPlacementParams { + pub tracks: SceneTracks, + pub subject: PlacementSubject, + pub time_spans: Vec<PlacementTimeSpan>, + pub strategy: PlacementStrategy, +} + +fn build_existing_track_result( + track: &Track, + track_index: usize, + tracks: &SceneTracks, + time_spans: &[PlacementTimeSpan], +) -> PlacementResult { + let first_span = time_spans.first(); + let requested_start_time = first_span + .map(|span| span.start_time) + .unwrap_or(MediaTime::ZERO); + let adjusted_start_time = enforce_main_track_start( + tracks, + track.id(), + requested_start_time, + first_span.and_then(|span| span.exclude_element_id.as_deref()), + ); + + PlacementResult::ExistingTrack { + track_id: track.id().to_string(), + track_index, + track_type: TrackType::of_track(track), + adjusted_start_time: (adjusted_start_time != requested_start_time) + .then_some(adjusted_start_time), + } +} + +fn build_new_track_result( + track_type: TrackType, + insert_index: usize, + insert_position: Option<InsertDirection>, +) -> PlacementResult { + PlacementResult::NewTrack { + track_type, + insert_index, + insert_position, + } +} + +fn find_first_available_track_index( + tracks: &[&Track], + track_type: TrackType, + time_spans: &[PlacementTimeSpan], +) -> Option<usize> { + tracks.iter().position(|track| { + TrackType::of_track(track) == track_type + && can_place_time_spans_on_track(track.elements(), time_spans) + }) +} + +fn resolve_always_new_track( + tracks: &SceneTracks, + track_type: TrackType, + position: NewTrackPosition, +) -> PlacementResult { + let insert_index = match position { + NewTrackPosition::Highest => get_highest_insert_index_for_track(tracks, track_type), + NewTrackPosition::Default => get_default_insert_index_for_track(tracks, track_type), + }; + + build_new_track_result(track_type, insert_index, None) +} + +fn get_insert_direction( + hover_direction: InsertDirection, + vertical_drag_direction: Option<VerticalDragDirection>, +) -> InsertDirection { + match vertical_drag_direction { + Some(VerticalDragDirection::Up) => InsertDirection::Above, + Some(VerticalDragDirection::Down) => InsertDirection::Below, + None => hover_direction, + } +} + +pub fn resolve_track_placement(params: &ResolveTrackPlacementParams) -> Option<PlacementResult> { + let tracks = ¶ms.tracks; + let ordered = ordered_tracks(tracks); + let track_type = match params.subject { + PlacementSubject::TrackType(track_type) => track_type, + PlacementSubject::ElementType(element_type) => { + get_track_type_for_element_type(element_type) + } + }; + let time_spans = ¶ms.time_spans; + + match ¶ms.strategy { + PlacementStrategy::Explicit { track_id } => { + let track_index = ordered.iter().position(|track| track.id() == track_id)?; + let track = ordered[track_index]; + if TrackType::of_track(track) != track_type { + return None; + } + + Some(build_existing_track_result( + track, + track_index, + tracks, + time_spans, + )) + } + PlacementStrategy::FirstAvailable => { + if let Some(existing_index) = + find_first_available_track_index(&ordered, track_type, time_spans) + { + return Some(build_existing_track_result( + ordered[existing_index], + existing_index, + tracks, + time_spans, + )); + } + + Some(resolve_always_new_track( + tracks, + track_type, + NewTrackPosition::Highest, + )) + } + PlacementStrategy::PreferIndex { + track_index, + hover_direction, + vertical_drag_direction, + create_new_track_only, + } => { + let preferred_track = ordered.get(*track_index); + let is_preferred_compatible = preferred_track + .is_some_and(|track| TrackType::of_track(track) == track_type); + let can_use_existing = !create_new_track_only + && is_preferred_compatible + && preferred_track.is_some_and(|track| { + can_place_time_spans_on_track(track.elements(), time_spans) + }); + if can_use_existing { + return Some(build_existing_track_result( + preferred_track?, + *track_index, + tracks, + time_spans, + )); + } + + let direction = get_insert_direction( + *hover_direction, + if is_preferred_compatible { + None + } else { + *vertical_drag_direction + }, + ); + let placement = resolve_preferred_new_track_placement( + tracks, + track_type, + *track_index, + direction, + ); + Some(build_new_track_result( + track_type, + placement.insert_index, + placement.insert_position, + )) + } + PlacementStrategy::AboveSource { source_track_index } => { + let above_track = source_track_index + .checked_sub(1) + .and_then(|above_index| ordered.get(above_index)); + if let Some(above_track) = above_track + && TrackType::of_track(above_track) == track_type + && can_place_time_spans_on_track(above_track.elements(), time_spans) + { + return Some(build_existing_track_result( + above_track, + source_track_index - 1, + tracks, + time_spans, + )); + } + + if let Some(first_available_index) = + find_first_available_track_index(&ordered, track_type, time_spans) + { + return Some(build_existing_track_result( + ordered[first_available_index], + first_available_index, + tracks, + time_spans, + )); + } + + Some(build_new_track_result( + track_type, + get_highest_insert_index_for_track(tracks, track_type), + None, + )) + } + PlacementStrategy::AlwaysNew { position } => Some(resolve_always_new_track( + tracks, + track_type, + *position, + )), + } +} diff --git a/rust/crates/timeline/src/placement/track_factory.rs b/rust/crates/timeline/src/placement/track_factory.rs new file mode 100644 index 00000000..0b86ecf2 --- /dev/null +++ b/rust/crates/timeline/src/placement/track_factory.rs @@ -0,0 +1,62 @@ +use scene::Track; + +use crate::types::TrackType; + +pub const DEFAULT_VIDEO_TRACK_NAME: &str = "Video track"; +pub const DEFAULT_TEXT_TRACK_NAME: &str = "Text track"; +pub const DEFAULT_AUDIO_TRACK_NAME: &str = "Audio track"; +pub const DEFAULT_GRAPHIC_TRACK_NAME: &str = "Graphic track"; +pub const DEFAULT_EFFECT_TRACK_NAME: &str = "Effect track"; + +pub fn default_track_name(track_type: TrackType) -> &'static str { + match track_type { + TrackType::Video => DEFAULT_VIDEO_TRACK_NAME, + TrackType::Text => DEFAULT_TEXT_TRACK_NAME, + TrackType::Audio => DEFAULT_AUDIO_TRACK_NAME, + TrackType::Graphic => DEFAULT_GRAPHIC_TRACK_NAME, + TrackType::Effect => DEFAULT_EFFECT_TRACK_NAME, + } +} + +pub fn build_empty_track( + id: impl Into<String>, + track_type: TrackType, + name: Option<String>, +) -> Track { + let id = id.into(); + let name = name.unwrap_or_else(|| default_track_name(track_type).to_string()); + + match track_type { + TrackType::Video => Track::Video { + id, + name, + elements: Vec::new(), + muted: false, + hidden: false, + }, + TrackType::Text => Track::Text { + id, + name, + elements: Vec::new(), + hidden: false, + }, + TrackType::Audio => Track::Audio { + id, + name, + elements: Vec::new(), + muted: false, + }, + TrackType::Graphic => Track::Graphic { + id, + name, + elements: Vec::new(), + hidden: false, + }, + TrackType::Effect => Track::Effect { + id, + name, + elements: Vec::new(), + hidden: false, + }, + } +} diff --git a/rust/crates/timeline/src/resize.rs b/rust/crates/timeline/src/resize.rs new file mode 100644 index 00000000..51478a09 --- /dev/null +++ b/rust/crates/timeline/src/resize.rs @@ -0,0 +1,401 @@ +use scene::{ElementRef, RetimeConfig, SceneTracks}; +use time::{FrameRate, MediaTime, TICKS_PER_SECOND}; + +use crate::retime::{ + get_source_span_at_clip_time, get_timeline_duration_for_source_span, round_media_time, +}; + +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub enum ResizeSide { + Left, + Right, +} + +#[derive(Clone, Debug, PartialEq)] +pub struct GroupResizePushTarget { + pub track_id: String, + pub element_id: String, + pub start_time: MediaTime, + pub duration: MediaTime, + pub trim_start: MediaTime, + pub trim_end: MediaTime, +} + +#[derive(Clone, Debug, PartialEq)] +pub struct GroupResizeMember { + pub track_id: String, + pub element_id: String, + pub start_time: MediaTime, + pub duration: MediaTime, + pub trim_start: MediaTime, + pub trim_end: MediaTime, + pub source_duration: Option<MediaTime>, + pub retime: Option<RetimeConfig>, + pub left_neighbor_bound: Option<MediaTime>, + pub right_neighbor_bound: Option<MediaTime>, + pub right_push_chain: Option<Vec<GroupResizePushTarget>>, +} + +#[derive(Clone, Debug, PartialEq)] +pub struct GroupResizePatch { + pub trim_start: MediaTime, + pub trim_end: MediaTime, + pub start_time: MediaTime, + pub duration: MediaTime, +} + +#[derive(Clone, Debug, PartialEq)] +pub struct GroupResizeUpdate { + pub track_id: String, + pub element_id: String, + pub patch: GroupResizePatch, +} + +#[derive(Clone, Debug, PartialEq)] +pub struct GroupResizeResult { + pub delta_time: MediaTime, + pub updates: Vec<GroupResizeUpdate>, +} + +pub fn compute_group_resize( + members: &[GroupResizeMember], + side: ResizeSide, + delta_time: MediaTime, + fps: FrameRate, +) -> GroupResizeResult { + if members.is_empty() { + return GroupResizeResult { + delta_time: MediaTime::ZERO, + updates: Vec::new(), + }; + } + + let min_duration = MediaTime::from_ticks( + ((TICKS_PER_SECOND as f64 * fps.denominator as f64) / fps.numerator as f64).round() as i64, + ); + let mut minimum_delta_time = get_minimum_allowed_delta_time(&members[0], side, min_duration); + let mut maximum_delta_time = get_maximum_allowed_delta_time(&members[0], side, min_duration); + + for member in &members[1..] { + minimum_delta_time = + minimum_delta_time.max(get_minimum_allowed_delta_time(member, side, min_duration)); + if let Some(member_maximum) = get_maximum_allowed_delta_time(member, side, min_duration) { + maximum_delta_time = Some(match maximum_delta_time { + None => member_maximum, + Some(current) => current.min(member_maximum), + }); + } + } + + let clamped_delta_time = match maximum_delta_time { + None => minimum_delta_time.max(delta_time), + Some(maximum) => delta_time.clamp(minimum_delta_time, maximum), + }; + + let snapped_delta_time = clamped_delta_time + .round_to_frame(fps) + .unwrap_or(clamped_delta_time); + let final_delta_time = match maximum_delta_time { + None => minimum_delta_time.max(snapped_delta_time), + Some(maximum) => snapped_delta_time.clamp(minimum_delta_time, maximum), + }; + + let mut updates: Vec<GroupResizeUpdate> = members + .iter() + .map(|member| build_resize_update(member, side, final_delta_time)) + .collect(); + updates.extend(build_ripple_push_updates(members, side, final_delta_time)); + + GroupResizeResult { + delta_time: final_delta_time, + updates, + } +} + +fn build_ripple_push_updates( + members: &[GroupResizeMember], + side: ResizeSide, + delta_time: MediaTime, +) -> Vec<GroupResizeUpdate> { + if side != ResizeSide::Right || delta_time <= MediaTime::ZERO { + return Vec::new(); + } + + let mut overflow_by_target: Vec<(GroupResizePushTarget, MediaTime)> = Vec::new(); + for member in members { + let (Some(right_neighbor_bound), Some(push_chain)) = + (member.right_neighbor_bound, &member.right_push_chain) + else { + continue; + }; + if member.source_duration.is_some() { + continue; + } + + let neighbor_ceiling = right_neighbor_bound - (member.start_time + member.duration); + let overflow = delta_time - neighbor_ceiling; + if overflow <= MediaTime::ZERO { + continue; + } + for target in push_chain { + match overflow_by_target + .iter_mut() + .find(|(existing, _)| existing.element_id == target.element_id) + { + Some((_, existing_overflow)) => { + if overflow > *existing_overflow { + *existing_overflow = overflow; + } + } + None => overflow_by_target.push((target.clone(), overflow)), + } + } + } + + overflow_by_target + .into_iter() + .map(|(target, overflow)| GroupResizeUpdate { + track_id: target.track_id, + element_id: target.element_id, + patch: GroupResizePatch { + trim_start: target.trim_start, + trim_end: target.trim_end, + start_time: target.start_time + overflow, + duration: target.duration, + }, + }) + .collect() +} + +fn build_resize_update( + member: &GroupResizeMember, + side: ResizeSide, + delta_time: MediaTime, +) -> GroupResizeUpdate { + let source_delta = get_source_delta_for_clip_delta(member, delta_time); + + match side { + ResizeSide::Left => GroupResizeUpdate { + track_id: member.track_id.clone(), + element_id: member.element_id.clone(), + patch: GroupResizePatch { + trim_start: MediaTime::ZERO.max(member.trim_start + source_delta), + trim_end: member.trim_end, + start_time: member.start_time + delta_time, + duration: member.duration - delta_time, + }, + }, + ResizeSide::Right => GroupResizeUpdate { + track_id: member.track_id.clone(), + element_id: member.element_id.clone(), + patch: GroupResizePatch { + trim_start: member.trim_start, + trim_end: MediaTime::ZERO.max(member.trim_end - source_delta), + start_time: member.start_time, + duration: member.duration + delta_time, + }, + }, + } +} + +fn get_minimum_allowed_delta_time( + member: &GroupResizeMember, + side: ResizeSide, + min_duration: MediaTime, +) -> MediaTime { + if side == ResizeSide::Right { + return min_duration - member.duration; + } + + let left_neighbor_floor = match member.left_neighbor_bound { + Some(bound) => bound - member.start_time, + None => MediaTime::ZERO - member.start_time, + }; + if member.source_duration.is_none() { + return left_neighbor_floor; + } + + let maximum_source_extension = get_duration_for_visible_source_span( + member, + get_visible_source_span_for_duration(member, member.duration) + member.trim_start, + ) - member.duration; + left_neighbor_floor.max(MediaTime::ZERO - maximum_source_extension) +} + +fn get_maximum_allowed_delta_time( + member: &GroupResizeMember, + side: ResizeSide, + min_duration: MediaTime, +) -> Option<MediaTime> { + if side == ResizeSide::Left { + return Some(member.duration - min_duration); + } + + let right_neighbor_ceiling = member + .right_neighbor_bound + .map(|bound| bound - (member.start_time + member.duration)); + if member.source_duration.is_none() { + return if member.right_push_chain.is_none() { + right_neighbor_ceiling + } else { + None + }; + } + + let maximum_visible_source_span = get_source_duration(member) - member.trim_start; + let maximum_duration = get_duration_for_visible_source_span(member, maximum_visible_source_span); + let source_duration_ceiling = maximum_duration - member.duration; + Some(match right_neighbor_ceiling { + None => source_duration_ceiling, + Some(ceiling) => ceiling.min(source_duration_ceiling), + }) +} + +fn get_source_delta_for_clip_delta(member: &GroupResizeMember, clip_delta: MediaTime) -> MediaTime { + let Some(retime) = &member.retime else { + return clip_delta; + }; + + let source_delta = if clip_delta >= MediaTime::ZERO { + get_source_span_at_clip_time(clip_delta.as_ticks() as f64, Some(retime)) + } else { + -get_source_span_at_clip_time(clip_delta.as_ticks().unsigned_abs() as f64, Some(retime)) + }; + round_media_time(source_delta) +} + +fn get_visible_source_span_for_duration(member: &GroupResizeMember, duration: MediaTime) -> MediaTime { + let Some(retime) = &member.retime else { + return duration; + }; + + round_media_time(get_source_span_at_clip_time( + duration.as_ticks() as f64, + Some(retime), + )) +} + +fn get_duration_for_visible_source_span( + member: &GroupResizeMember, + source_span: MediaTime, +) -> MediaTime { + let Some(retime) = &member.retime else { + return source_span; + }; + + round_media_time(get_timeline_duration_for_source_span( + source_span.as_ticks() as f64, + Some(retime), + )) +} + +fn get_source_duration(member: &GroupResizeMember) -> MediaTime { + if let Some(source_duration) = member.source_duration { + return source_duration; + } + + member.trim_start + + get_visible_source_span_for_duration(member, member.duration) + + member.trim_end +} + +pub fn build_resize_members( + tracks: &SceneTracks, + selected_elements: &[ElementRef], +) -> Vec<GroupResizeMember> { + let selected_ids: Vec<&str> = selected_elements + .iter() + .map(|reference| reference.element_id.as_str()) + .collect(); + + let mut members = Vec::new(); + for reference in selected_elements { + let Some(track) = crate::track_element_update::find_track_in_scene_tracks( + tracks, + &reference.track_id, + ) else { + continue; + }; + let Some(element) = track + .elements() + .iter() + .find(|element| element.base().id == reference.element_id) + else { + continue; + }; + + let base = element.base(); + let other_elements: Vec<&scene::Element> = track + .elements() + .iter() + .filter(|candidate| !selected_ids.contains(&candidate.base().id.as_str())) + .collect(); + + let left_neighbor_bound = other_elements + .iter() + .filter(|candidate| { + candidate.base().start_time + candidate.base().duration <= base.start_time + }) + .map(|candidate| candidate.base().start_time + candidate.base().duration) + .reduce(MediaTime::max); + + let mut right_elements: Vec<&scene::Element> = other_elements + .iter() + .filter(|candidate| candidate.base().start_time >= base.start_time + base.duration) + .copied() + .collect(); + let right_neighbor_bound = right_elements + .iter() + .map(|candidate| candidate.base().start_time) + .reduce(MediaTime::min); + + let right_push_chain = if base.source_duration.is_none() { + right_elements.sort_by_key(|candidate| candidate.base().start_time); + Some( + right_elements + .iter() + .map(|candidate| GroupResizePushTarget { + track_id: reference.track_id.clone(), + element_id: candidate.base().id.clone(), + start_time: candidate.base().start_time, + duration: candidate.base().duration, + trim_start: candidate.base().trim_start, + trim_end: candidate.base().trim_end, + }) + .collect(), + ) + } else { + None + }; + + members.push(GroupResizeMember { + track_id: reference.track_id.clone(), + element_id: reference.element_id.clone(), + start_time: base.start_time, + duration: base.duration, + trim_start: base.trim_start, + trim_end: base.trim_end, + source_duration: base.source_duration, + retime: crate::element_retime(element), + left_neighbor_bound, + right_neighbor_bound, + right_push_chain, + }); + } + + members +} + +pub fn has_resize_changes(members: &[GroupResizeMember], result: &GroupResizeResult) -> bool { + result.updates.iter().any(|update| { + let member = members + .iter() + .find(|member| member.element_id == update.element_id); + member.is_some_and(|member| { + member.trim_start != update.patch.trim_start + || member.trim_end != update.patch.trim_end + || member.start_time != update.patch.start_time + || member.duration != update.patch.duration + }) + }) +} diff --git a/rust/crates/timeline/src/retime.rs b/rust/crates/timeline/src/retime.rs new file mode 100644 index 00000000..7215f2ec --- /dev/null +++ b/rust/crates/timeline/src/retime.rs @@ -0,0 +1,71 @@ +use scene::RetimeConfig; +use time::MediaTime; + +pub const DEFAULT_RETIME_RATE: f64 = 1.0; +pub const MIN_RETIME_RATE: f64 = 0.01; +pub const MAX_RETIME_RATE: f64 = 5.0; + +pub 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) +} + +pub fn can_maintain_pitch(rate: f64) -> bool { + rate.is_finite() && rate > 0.0 +} + +pub fn should_maintain_pitch(rate: f64, maintain_pitch: Option<bool>) -> bool { + maintain_pitch == Some(true) && can_maintain_pitch(rate) +} + +fn safe_rate(retime: Option<&RetimeConfig>) -> f64 { + clamp_retime_rate(retime.map(|retime| retime.rate).unwrap_or(1.0)) +} + +pub fn get_source_time_at_clip_time(clip_time: f64, retime: Option<&RetimeConfig>) -> f64 { + clip_time * safe_rate(retime) +} + +pub fn get_clip_time_at_source_time(source_time: f64, retime: Option<&RetimeConfig>) -> f64 { + source_time / safe_rate(retime) +} + +pub fn get_effective_rate_at(retime: Option<&RetimeConfig>) -> f64 { + safe_rate(retime) +} + +pub fn get_timeline_duration_for_source_span( + source_span: f64, + retime: Option<&RetimeConfig>, +) -> f64 { + if source_span <= 0.0 { + return 0.0; + } + source_span / safe_rate(retime) +} + +pub fn get_source_span_at_clip_time(clip_time: f64, retime: Option<&RetimeConfig>) -> f64 { + get_source_time_at_clip_time(clip_time, retime).max(0.0) +} + +pub fn split_retime_at_clip_time( + retime: Option<&RetimeConfig>, +) -> (Option<RetimeConfig>, Option<RetimeConfig>) { + (retime.copied(), retime.copied()) +} + +pub fn adjust_retime_for_trim_change(retime: Option<&RetimeConfig>) -> Option<RetimeConfig> { + retime.copied() +} + +pub fn round_media_time(time: f64) -> MediaTime { + let rounded_magnitude = time.abs().round(); + if rounded_magnitude == 0.0 { + return MediaTime::ZERO; + } + let magnitude = rounded_magnitude as i64; + MediaTime::from_ticks(if time < 0.0 { -magnitude } else { magnitude }) +} diff --git a/rust/crates/timeline/src/ripple.rs b/rust/crates/timeline/src/ripple.rs new file mode 100644 index 00000000..0ed88fc4 --- /dev/null +++ b/rust/crates/timeline/src/ripple.rs @@ -0,0 +1,265 @@ +use std::collections::{HashMap, HashSet}; + +use scene::{Element, SceneTracks}; +use time::MediaTime; + +#[derive(Clone, Debug, PartialEq)] +pub struct RippleAdjustment { + pub track_id: String, + pub after_time: MediaTime, + pub shift_amount: MediaTime, +} + +#[derive(Clone, Copy, Debug, PartialEq)] +struct Interval { + start_time: MediaTime, + end_time: MediaTime, +} + +#[derive(Clone, Copy, Debug)] +struct ElementSpan { + start_time: MediaTime, + end_time: MediaTime, +} + +pub fn compute_ripple_adjustments( + before_tracks: &SceneTracks, + after_tracks: &SceneTracks, +) -> Vec<RippleAdjustment> { + let before_list = crate::placement::ordered_tracks(before_tracks); + let after_list = crate::placement::ordered_tracks(after_tracks); + let after_tracks_by_id: HashMap<&str, &scene::Track> = after_list + .iter() + .map(|track| (track.id(), *track)) + .collect(); + let all_after_element_ids: HashSet<&str> = after_list + .iter() + .flat_map(|track| track.elements().iter().map(|element| element.base().id.as_str())) + .collect(); + + let mut adjustments = Vec::new(); + for before_track in before_list { + let after_elements = after_tracks_by_id + .get(before_track.id()) + .map(|track| track.elements()) + .unwrap_or(&[]); + adjustments.extend(compute_track_ripple_adjustments( + before_track.id(), + before_track.elements(), + after_elements, + &all_after_element_ids, + )); + } + adjustments +} + +fn compute_track_ripple_adjustments( + track_id: &str, + before_elements: &[Element], + after_elements: &[Element], + all_after_element_ids: &HashSet<&str>, +) -> Vec<RippleAdjustment> { + let before_spans = build_element_span_map(before_elements); + let after_spans = build_element_span_map(after_elements); + let (vacated, joined) = collect_track_intervals(&before_spans, &after_spans, all_after_element_ids); + let freed = subtract_interval_sets(&vacated, &joined); + build_adjustments(track_id, &freed) +} + +fn build_element_span_map(elements: &[Element]) -> HashMap<String, ElementSpan> { + elements + .iter() + .map(|element| { + ( + element.base().id.clone(), + ElementSpan { + start_time: element.base().start_time, + end_time: element.base().start_time + element.base().duration, + }, + ) + }) + .collect() +} + +fn collect_track_intervals( + before_spans: &HashMap<String, ElementSpan>, + after_spans: &HashMap<String, ElementSpan>, + all_after_element_ids: &HashSet<&str>, +) -> (Vec<Interval>, Vec<Interval>) { + let mut vacated = Vec::new(); + let mut joined = Vec::new(); + + for (id, before_span) in before_spans { + match after_spans.get(id) { + None => { + if !all_after_element_ids.contains(id.as_str()) { + push_interval(&mut vacated, before_span.start_time, before_span.end_time); + } + } + Some(after_span) => { + if before_span.end_time > after_span.end_time { + push_interval(&mut vacated, after_span.end_time, before_span.end_time); + } + } + } + } + + for (id, after_span) in after_spans { + if before_spans.contains_key(id) { + continue; + } + push_interval(&mut joined, after_span.start_time, after_span.end_time); + } + + (normalize_intervals(vacated), normalize_intervals(joined)) +} + +fn build_adjustments(track_id: &str, intervals: &[Interval]) -> Vec<RippleAdjustment> { + intervals + .iter() + .filter_map(|interval| { + let shift_amount = interval.end_time - interval.start_time; + if shift_amount <= MediaTime::ZERO { + return None; + } + Some(RippleAdjustment { + track_id: track_id.to_string(), + after_time: interval.end_time, + shift_amount, + }) + }) + .collect() +} + +fn subtract_interval_sets(source: &[Interval], overlapping: &[Interval]) -> Vec<Interval> { + let normalized_source = normalize_intervals(source.to_vec()); + let normalized_overlapping = normalize_intervals(overlapping.to_vec()); + + normalized_source + .iter() + .flat_map(|source_interval| subtract_single_interval(*source_interval, &normalized_overlapping)) + .collect() +} + +fn normalize_intervals(intervals: Vec<Interval>) -> Vec<Interval> { + let mut valid = Vec::new(); + for interval in intervals { + push_interval(&mut valid, interval.start_time, interval.end_time); + } + valid.sort_by_key(|interval| interval.start_time); + + let mut merged: Vec<Interval> = Vec::new(); + for interval in valid { + match merged.last_mut() { + Some(previous) if interval.start_time <= previous.end_time => { + previous.end_time = previous.end_time.max(interval.end_time); + } + _ => merged.push(interval), + } + } + merged +} + +fn subtract_single_interval(source: Interval, overlapping: &[Interval]) -> Vec<Interval> { + let mut remaining = vec![source]; + + for overlapping_interval in overlapping { + let mut next = Vec::new(); + for interval in remaining { + if overlapping_interval.end_time <= interval.start_time + || overlapping_interval.start_time >= interval.end_time + { + next.push(interval); + continue; + } + + push_interval(&mut next, interval.start_time, overlapping_interval.start_time); + push_interval(&mut next, overlapping_interval.end_time, interval.end_time); + } + remaining = next; + + if remaining.is_empty() { + return Vec::new(); + } + } + + remaining +} + +fn push_interval(intervals: &mut Vec<Interval>, start_time: MediaTime, end_time: MediaTime) { + if end_time <= start_time { + return; + } + intervals.push(Interval { start_time, end_time }); +} + +pub fn ripple_shift_elements( + elements: &[Element], + after_time: MediaTime, + shift_amount: MediaTime, +) -> Vec<Element> { + elements + .iter() + .map(|element| { + if element.base().start_time >= after_time { + let mut shifted = element.clone(); + shifted.base_mut().start_time = element.base().start_time - shift_amount; + shifted + } else { + element.clone() + } + }) + .collect() +} + +pub fn apply_ripple_adjustments( + tracks: &SceneTracks, + adjustments: &[RippleAdjustment], +) -> SceneTracks { + if adjustments.is_empty() { + return tracks.clone(); + } + + let mut adjustments_by_track: HashMap<&str, Vec<&RippleAdjustment>> = HashMap::new(); + for adjustment in adjustments { + adjustments_by_track + .entry(adjustment.track_id.as_str()) + .or_default() + .push(adjustment); + } + + let apply_to_track = |track: &scene::Track| { + let track_adjustments = adjustments_by_track + .get(track.id()) + .map(Vec::as_slice) + .unwrap_or(&[]); + apply_track_ripple_adjustments(track, track_adjustments) + }; + + SceneTracks { + overlay: tracks.overlay.iter().map(apply_to_track).collect(), + main: apply_to_track(&tracks.main), + audio: tracks.audio.iter().map(apply_to_track).collect(), + } +} + +fn apply_track_ripple_adjustments( + track: &scene::Track, + adjustments: &[&RippleAdjustment], +) -> scene::Track { + if adjustments.is_empty() { + return track.clone(); + } + + let mut sorted: Vec<&RippleAdjustment> = adjustments.to_vec(); + sorted.sort_by_key(|adjustment| std::cmp::Reverse(adjustment.after_time)); + + let mut elements = track.elements().to_vec(); + for adjustment in sorted { + elements = ripple_shift_elements(&elements, adjustment.after_time, adjustment.shift_amount); + } + + let mut next = track.clone(); + *next.elements_mut() = elements; + next +} diff --git a/rust/crates/timeline/src/scenes.rs b/rust/crates/timeline/src/scenes.rs new file mode 100644 index 00000000..8a7a5c4f --- /dev/null +++ b/rust/crates/timeline/src/scenes.rs @@ -0,0 +1,120 @@ +use chrono::{DateTime, Utc}; +use scene::{Scene, SceneTracks}; +use time::MediaTime; + +use crate::placement::MAIN_TRACK_NAME; +use crate::placement::track_factory::build_empty_track; +use crate::types::TrackType; + +pub fn get_main_scene(scenes: &[Scene]) -> Option<&Scene> { + scenes.iter().find(|scene| scene.is_main) +} + +pub fn ensure_main_scene(scenes: &[Scene], next_id: &mut dyn FnMut() -> String, now: DateTime<Utc>) -> Vec<Scene> { + if scenes.iter().any(|scene| scene.is_main) { + return scenes.to_vec(); + } + + let main_scene = build_default_scene( + next_id(), + next_id(), + "Main scene".to_string(), + true, + now, + ); + let mut next = Vec::with_capacity(scenes.len() + 1); + next.push(main_scene); + next.extend(scenes.iter().cloned()); + next +} + +pub fn build_default_scene( + id: impl Into<String>, + main_track_id: impl Into<String>, + name: String, + is_main: bool, + now: DateTime<Utc>, +) -> Scene { + Scene { + id: id.into(), + name, + is_main, + tracks: SceneTracks { + overlay: Vec::new(), + main: build_empty_track( + main_track_id, + TrackType::Video, + Some(MAIN_TRACK_NAME.to_string()), + ), + audio: Vec::new(), + }, + bookmarks: Vec::new(), + created_at: now, + updated_at: now, + } +} + +pub struct SceneDeleteCheck { + pub can_delete: bool, + pub reason: Option<String>, +} + +pub fn can_delete_scene(scene: &Scene) -> SceneDeleteCheck { + if scene.is_main { + return SceneDeleteCheck { + can_delete: false, + reason: Some("Cannot delete main scene".to_string()), + }; + } + SceneDeleteCheck { + can_delete: true, + reason: None, + } +} + +pub fn get_fallback_scene_after_delete<'a>( + scenes: &'a [Scene], + deleted_scene_id: &str, + current_scene_id: Option<&str>, +) -> Option<&'a Scene> { + if current_scene_id != Some(deleted_scene_id) { + return scenes.iter().find(|scene| Some(scene.id.as_str()) == current_scene_id); + } + get_main_scene(scenes) +} + +pub fn find_current_scene<'a>( + scenes: &'a [Scene], + current_scene_id: &str, +) -> Option<&'a Scene> { + scenes + .iter() + .find(|scene| scene.id == current_scene_id) + .or_else(|| get_main_scene(scenes)) + .or_else(|| scenes.first()) +} + +pub fn get_project_duration_from_scenes(scenes: &[Scene]) -> MediaTime { + let Some(main_scene) = get_main_scene(scenes).or_else(|| scenes.first()) else { + return MediaTime::ZERO; + }; + + crate::calculate_total_duration(&main_scene.tracks) +} + +pub fn update_scene_in_array( + scenes: &[Scene], + scene_id: &str, + update: impl Fn(&Scene) -> Scene, +) -> Vec<Scene> { + scenes + .iter() + .map(|scene| { + if scene.id == scene_id { + update(scene) + } else { + scene.clone() + } + }) + .collect() +} diff --git a/rust/crates/timeline/src/snap.rs b/rust/crates/timeline/src/snap.rs new file mode 100644 index 00000000..1133726f --- /dev/null +++ b/rust/crates/timeline/src/snap.rs @@ -0,0 +1,113 @@ +use std::collections::HashSet; + +use scene::SceneTracks; +use time::MediaTime; + +pub const BASE_TIMELINE_PIXELS_PER_SECOND: f64 = 50.0; +pub const TIMELINE_ZOOM_MIN: f64 = 0.1; +pub const TIMELINE_ZOOM_MAX: f64 = 100.0; +pub const DEFAULT_TIMELINE_SNAP_THRESHOLD_PX: f64 = 10.0; + +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub enum SnapPointType { + ElementStart, + ElementEnd, + Playhead, + Bookmark, + Keyframe, +} + +#[derive(Clone, Debug, PartialEq)] +pub struct SnapPoint { + pub time: MediaTime, + pub kind: SnapPointType, + pub element_id: Option<String>, + pub track_id: Option<String>, +} + +#[derive(Clone, Debug, PartialEq)] +pub struct SnapResult { + pub snapped_time: MediaTime, + pub snap_point: Option<SnapPoint>, + pub snap_distance: f64, +} + +pub fn resolve_timeline_snap( + target_time: MediaTime, + snap_points: &[SnapPoint], + max_snap_distance: f64, +) -> SnapResult { + let mut closest_snap_point: Option<&SnapPoint> = None; + let mut closest_distance = f64::INFINITY; + + for snap_point in snap_points { + let distance = (target_time - snap_point.time).as_ticks().unsigned_abs() as f64; + if distance <= max_snap_distance && distance < closest_distance { + closest_distance = distance; + closest_snap_point = Some(snap_point); + } + } + + SnapResult { + snapped_time: closest_snap_point + .map(|snap_point| snap_point.time) + .unwrap_or(target_time), + snap_point: closest_snap_point.cloned(), + snap_distance: closest_distance, + } +} + +pub fn get_timeline_snap_threshold_in_ticks( + zoom_level: f64, + snap_threshold_px: Option<f64>, +) -> f64 { + let threshold_px = snap_threshold_px.unwrap_or(DEFAULT_TIMELINE_SNAP_THRESHOLD_PX); + let pixels_per_second = BASE_TIMELINE_PIXELS_PER_SECOND * zoom_level; + (threshold_px / pixels_per_second) * time::TICKS_PER_SECOND as f64 +} + +pub fn build_timeline_snap_points( + sources: impl IntoIterator<Item = Vec<SnapPoint>>, +) -> Vec<SnapPoint> { + sources.into_iter().flatten().collect() +} + +pub fn get_element_edge_snap_points( + tracks: &SceneTracks, + exclude_element_ids: Option<&HashSet<&str>>, +) -> Vec<SnapPoint> { + let mut snap_points = Vec::new(); + + for track in crate::placement::ordered_tracks(tracks) { + for element in track.elements() { + let base = element.base(); + if exclude_element_ids.is_some_and(|ids| ids.contains(base.id.as_str())) { + continue; + } + + snap_points.push(SnapPoint { + time: base.start_time, + kind: SnapPointType::ElementStart, + element_id: Some(base.id.clone()), + track_id: Some(track.id().to_string()), + }); + snap_points.push(SnapPoint { + time: base.start_time + base.duration, + kind: SnapPointType::ElementEnd, + element_id: Some(base.id.clone()), + track_id: Some(track.id().to_string()), + }); + } + } + + snap_points +} + +pub fn get_playhead_snap_points(playhead_time: MediaTime) -> Vec<SnapPoint> { + vec![SnapPoint { + time: playhead_time, + kind: SnapPointType::Playhead, + element_id: None, + track_id: None, + }] +} diff --git a/rust/crates/timeline/src/split.rs b/rust/crates/timeline/src/split.rs new file mode 100644 index 00000000..5e7332b1 --- /dev/null +++ b/rust/crates/timeline/src/split.rs @@ -0,0 +1,143 @@ +use scene::{Element, ElementRef, SceneTracks}; +use time::MediaTime; + +use crate::retime::{get_source_span_at_clip_time, round_media_time}; + +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub enum RetainSide { + Both, + Left, + Right, +} + +#[derive(Clone, Debug, PartialEq)] +pub struct SplitElementsResult { + pub tracks: SceneTracks, + pub right_side_elements: Vec<ElementRef>, +} + +pub fn split_elements( + tracks: &SceneTracks, + targets: &[ElementRef], + split_time: MediaTime, + retain_side: RetainSide, +) -> SplitElementsResult { + let mut right_side_elements = Vec::new(); + + let split_track = |track: &scene::Track, right_side_elements: &mut Vec<ElementRef>| { + let has_targets = targets.iter().any(|target| target.track_id == track.id()); + if !has_targets { + return track.clone(); + } + + let elements = track + .elements() + .iter() + .flat_map(|element| { + let should_split = targets + .iter() + .any(|target| target.element_id == element.base().id); + if !should_split { + return vec![element.clone()]; + } + split_element(track.id(), element, split_time, retain_side, right_side_elements) + }) + .collect(); + + let mut next = track.clone(); + *next.elements_mut() = elements; + next + }; + + let updated = SceneTracks { + overlay: tracks + .overlay + .iter() + .map(|track| split_track(track, &mut right_side_elements)) + .collect(), + main: split_track(&tracks.main, &mut right_side_elements), + audio: tracks + .audio + .iter() + .map(|track| split_track(track, &mut right_side_elements)) + .collect(), + }; + + SplitElementsResult { + tracks: updated, + right_side_elements, + } +} + +fn split_element( + track_id: &str, + element: &Element, + split_time: MediaTime, + retain_side: RetainSide, + right_side_elements: &mut Vec<ElementRef>, +) -> Vec<Element> { + let base = element.base(); + let effective_start = base.start_time; + let effective_end = base.start_time + base.duration; + + if split_time <= effective_start || split_time >= effective_end { + return vec![element.clone()]; + } + + let relative_time = split_time - base.start_time; + let left_visible_duration = relative_time; + let right_visible_duration = base.duration - relative_time; + let retime = crate::element_retime(element); + + let left_source_span = round_media_time(get_source_span_at_clip_time( + left_visible_duration.as_ticks() as f64, + retime.as_ref(), + )); + let total_source_span = round_media_time(get_source_span_at_clip_time( + base.duration.as_ticks() as f64, + retime.as_ref(), + )); + let right_source_span = total_source_span - left_source_span; + + let (left_animations, right_animations) = animation::split_animations_at_time_with_options( + base.animations.as_ref(), + relative_time, + true, + ); + + let left_trim_end = base.trim_end + right_source_span; + let right_trim_start = base.trim_start + left_source_span; + + let build_left = || { + let mut left = element.clone(); + let left_base = left.base_mut(); + left_base.duration = left_visible_duration; + left_base.trim_end = left_trim_end; + left_base.name = format!("{} (left)", left_base.name); + left_base.animations = left_animations; + left + }; + + let build_right = |right_side_elements: &mut Vec<ElementRef>| { + let new_id = animation::generate_uuid(); + right_side_elements.push(ElementRef { + track_id: track_id.to_string(), + element_id: new_id.clone(), + }); + let mut right = element.clone(); + let right_base = right.base_mut(); + right_base.id = new_id; + right_base.start_time = split_time; + right_base.duration = right_visible_duration; + right_base.trim_start = right_trim_start; + right_base.name = format!("{} (right)", right_base.name); + right_base.animations = right_animations; + right + }; + + match retain_side { + RetainSide::Left => vec![build_left()], + RetainSide::Right => vec![build_right(right_side_elements)], + RetainSide::Both => vec![build_left(), build_right(right_side_elements)], + } +} diff --git a/rust/crates/timeline/src/track_capabilities.rs b/rust/crates/timeline/src/track_capabilities.rs new file mode 100644 index 00000000..fe93316f --- /dev/null +++ b/rust/crates/timeline/src/track_capabilities.rs @@ -0,0 +1,9 @@ +use scene::Track; + +pub fn can_track_have_audio(track: &Track) -> bool { + matches!(track, Track::Audio { .. } | Track::Video { .. }) +} + +pub fn can_track_be_hidden(track: &Track) -> bool { + !matches!(track, Track::Audio { .. }) +} diff --git a/rust/crates/timeline/src/track_element_update.rs b/rust/crates/timeline/src/track_element_update.rs new file mode 100644 index 00000000..98395ab0 --- /dev/null +++ b/rust/crates/timeline/src/track_element_update.rs @@ -0,0 +1,97 @@ +use scene::{Element, SceneTracks, Track}; + +pub fn find_track_in_scene_tracks<'a>(tracks: &'a SceneTracks, track_id: &str) -> Option<&'a Track> { + if tracks.main.id() == track_id { + return Some(&tracks.main); + } + + tracks + .overlay + .iter() + .chain(tracks.audio.iter()) + .find(|track| track.id() == track_id) +} + +pub fn update_track_in_scene_tracks( + tracks: &SceneTracks, + track_id: &str, + update: impl Fn(&Track) -> Track, +) -> SceneTracks { + if tracks.main.id() == track_id { + return SceneTracks { + main: update(&tracks.main), + ..tracks.clone() + }; + } + + if tracks.overlay.iter().any(|track| track.id() == track_id) { + return SceneTracks { + overlay: tracks + .overlay + .iter() + .map(|track| { + if track.id() == track_id { + update(track) + } else { + track.clone() + } + }) + .collect(), + ..tracks.clone() + }; + } + + if tracks.audio.iter().any(|track| track.id() == track_id) { + return SceneTracks { + audio: tracks + .audio + .iter() + .map(|track| { + if track.id() == track_id { + update(track) + } else { + track.clone() + } + }) + .collect(), + ..tracks.clone() + }; + } + + tracks.clone() +} + +fn with_replaced_elements(track: &Track, elements: Vec<Element>) -> Track { + let mut next = track.clone(); + *next.elements_mut() = elements; + next +} + +pub fn update_element_in_scene_tracks( + tracks: &SceneTracks, + track_id: &str, + element_id: &str, + update: impl Fn(&Element) -> Element, + element_predicate: Option<&dyn Fn(&Element) -> bool>, +) -> SceneTracks { + let update_in_track = |track: &Track| { + let elements = track + .elements() + .iter() + .map(|element| { + if element.base().id != element_id { + return element.clone(); + } + if let Some(predicate) = element_predicate + && !predicate(element) + { + return element.clone(); + } + update(element) + }) + .collect(); + with_replaced_elements(track, elements) + }; + + update_track_in_scene_tracks(tracks, track_id, update_in_track) +} diff --git a/rust/crates/timeline/src/types.rs b/rust/crates/timeline/src/types.rs new file mode 100644 index 00000000..b142c2ea --- /dev/null +++ b/rust/crates/timeline/src/types.rs @@ -0,0 +1,69 @@ +use scene::{Element, Track}; + +#[derive(Clone, Copy, Debug, Eq, PartialEq, Hash)] +pub enum TrackType { + Video, + Text, + Audio, + Graphic, + Effect, +} + +impl TrackType { + pub fn as_str(self) -> &'static str { + match self { + TrackType::Video => "video", + TrackType::Text => "text", + TrackType::Audio => "audio", + TrackType::Graphic => "graphic", + TrackType::Effect => "effect", + } + } + + pub fn of_track(track: &Track) -> TrackType { + match track { + Track::Video { .. } => TrackType::Video, + Track::Text { .. } => TrackType::Text, + Track::Audio { .. } => TrackType::Audio, + Track::Graphic { .. } => TrackType::Graphic, + Track::Effect { .. } => TrackType::Effect, + } + } +} + +#[derive(Clone, Copy, Debug, Eq, PartialEq, Hash)] +pub enum ElementType { + Audio, + Video, + Image, + Text, + Sticker, + Graphic, + Effect, +} + +impl ElementType { + pub fn as_str(self) -> &'static str { + match self { + ElementType::Audio => "audio", + ElementType::Video => "video", + ElementType::Image => "image", + ElementType::Text => "text", + ElementType::Sticker => "sticker", + ElementType::Graphic => "graphic", + ElementType::Effect => "effect", + } + } + + pub fn of_element(element: &Element) -> ElementType { + match element { + Element::Audio(_) => ElementType::Audio, + Element::Video(_) => ElementType::Video, + Element::Image(_) => ElementType::Image, + Element::Text(_) => ElementType::Text, + Element::Sticker(_) => ElementType::Sticker, + Element::Graphic(_) => ElementType::Graphic, + Element::Effect(_) => ElementType::Effect, + } + } +} diff --git a/rust/crates/timeline/src/update_pipeline.rs b/rust/crates/timeline/src/update_pipeline.rs new file mode 100644 index 00000000..83b03d85 --- /dev/null +++ b/rust/crates/timeline/src/update_pipeline.rs @@ -0,0 +1,158 @@ +use std::collections::HashSet; + +use scene::{Element, ElementAnimations, ParamValues, RetimeConfig, SceneTracks}; +use time::MediaTime; + +use crate::retime::{ + clamp_retime_rate, get_source_span_at_clip_time, get_timeline_duration_for_source_span, + round_media_time, +}; + +#[derive(Clone, Debug, Default)] +pub struct ElementPatch { + pub name: Option<String>, + pub duration: Option<MediaTime>, + pub start_time: Option<MediaTime>, + pub trim_start: Option<MediaTime>, + pub trim_end: Option<MediaTime>, + pub source_duration: Option<Option<MediaTime>>, + pub animations: Option<Option<ElementAnimations>>, + pub params: Option<ParamValues>, + pub retime: Option<Option<RetimeConfig>>, +} + +pub struct ElementUpdateContext<'a> { + pub tracks: &'a SceneTracks, + pub track_id: &'a str, +} + +#[derive(Clone, Copy, Debug, Eq, PartialEq, Hash)] +enum UpdateField { + Retime, + Duration, + StartTime, +} + +pub fn apply_element_update( + element: &Element, + patch: &ElementPatch, + context: &ElementUpdateContext, +) -> Element { + let mut next_element = merge_patch(element, patch); + let mut changed_fields: HashSet<UpdateField> = HashSet::new(); + if patch.retime.is_some() { + changed_fields.insert(UpdateField::Retime); + } + if patch.duration.is_some() { + changed_fields.insert(UpdateField::Duration); + } + if patch.start_time.is_some() { + changed_fields.insert(UpdateField::StartTime); + } + + if changed_fields.contains(&UpdateField::Retime) + && let Some(patch_retime) = &patch.retime + && crate::is_retimable_element(&next_element) + { + let next_retime = patch_retime.map(|retime| RetimeConfig { + rate: clamp_retime_rate(retime.rate), + maintain_pitch: retime.maintain_pitch, + }); + + let source_duration = get_source_duration(element); + let visible_source_span = (source_duration + - next_element.base().trim_start.as_ticks() as f64 + - next_element.base().trim_end.as_ticks() as f64) + .max(0.0); + let next_duration = round_media_time(get_timeline_duration_for_source_span( + visible_source_span, + next_retime.as_ref(), + )); + + crate::set_element_retime(&mut next_element, next_retime); + next_element.base_mut().duration = next_duration; + changed_fields.insert(UpdateField::Duration); + } + + if changed_fields.contains(&UpdateField::Duration) { + next_element.base_mut().animations = animation::clamp_animations_to_duration( + next_element.base().animations.as_ref(), + next_element.base().duration, + ); + } + + if changed_fields.contains(&UpdateField::StartTime) { + let requested_start_time = next_element.base().start_time.max(MediaTime::ZERO); + if context.track_id != context.tracks.main.id() { + next_element.base_mut().start_time = requested_start_time; + } else { + let earliest = context + .tracks + .main + .elements() + .iter() + .filter(|candidate| candidate.base().id != next_element.base().id) + .reduce(|earliest, candidate| { + if candidate.base().start_time < earliest.base().start_time { + candidate + } else { + earliest + } + }); + + next_element.base_mut().start_time = match earliest { + Some(earliest) if requested_start_time > earliest.base().start_time => { + requested_start_time + } + _ => MediaTime::ZERO, + }; + } + } + + next_element +} + +fn merge_patch(element: &Element, patch: &ElementPatch) -> Element { + let mut next = element.clone(); + let base = next.base_mut(); + if let Some(name) = &patch.name { + base.name = name.clone(); + } + if let Some(duration) = patch.duration { + base.duration = duration; + } + if let Some(start_time) = patch.start_time { + base.start_time = start_time; + } + if let Some(trim_start) = patch.trim_start { + base.trim_start = trim_start; + } + if let Some(trim_end) = patch.trim_end { + base.trim_end = trim_end; + } + if let Some(source_duration) = patch.source_duration { + base.source_duration = source_duration; + } + if let Some(animations) = &patch.animations { + base.animations = animations.clone(); + } + if let Some(params) = &patch.params { + base.params.extend(params.clone()); + } + if let Some(retime) = &patch.retime { + crate::set_element_retime(&mut next, *retime); + } + next +} + +fn get_source_duration(element: &Element) -> f64 { + let base = element.base(); + if let Some(source_duration) = base.source_duration { + return source_duration.as_ticks() as f64; + } + + let retime = crate::element_retime(element); + base.trim_start.as_ticks() as f64 + + get_source_span_at_clip_time(base.duration.as_ticks() as f64, retime.as_ref()) + + base.trim_end.as_ticks() as f64 +} diff --git a/rust/crates/transfer/Cargo.toml b/rust/crates/transfer/Cargo.toml new file mode 100644 index 00000000..f13cb413 --- /dev/null +++ b/rust/crates/transfer/Cargo.toml @@ -0,0 +1,10 @@ +[package] +name = "transfer" +version = "0.1.0" +edition = "2024" + +[dependencies] +serde = { version = "1.0", features = ["derive"] } +serde_json = "1.0" +thiserror = "2" +zip = { version = "2", default-features = false } diff --git a/rust/crates/transfer/src/container.rs b/rust/crates/transfer/src/container.rs new file mode 100644 index 00000000..bcdc2494 --- /dev/null +++ b/rust/crates/transfer/src/container.rs @@ -0,0 +1,277 @@ +use std::collections::BTreeMap; +use std::io::{Cursor, Read, Write}; + +use serde::{Deserialize, Serialize}; +use zip::write::SimpleFileOptions; +use zip::{ZipArchive, ZipWriter}; + +pub const PROJECT_ARCHIVE_EXTENSION: &str = ".ocp"; +pub const PROJECT_JSON_ENTRY: &str = "project.json"; +pub const MEDIA_MANIFEST_ENTRY: &str = "media/manifest.json"; +pub const MEDIA_ENTRY_PREFIX: &str = "media/"; + +const MEDIA_MANIFEST_VERSION: u32 = 1; + +#[derive(Debug, thiserror::Error)] +#[error("{message}")] +pub struct ProjectArchiveError { + message: String, +} + +impl ProjectArchiveError { + fn new(message: impl Into<String>) -> Self { + Self { + message: message.into(), + } + } +} + +#[derive(Serialize, Deserialize, Clone, Debug, PartialEq)] +#[serde(rename_all = "camelCase")] +pub struct ArchivedMediaMetadata { + pub id: String, + pub name: String, + pub media_type: String, + pub file_type: String, + pub last_modified: f64, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub width: Option<f64>, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub height: Option<f64>, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub duration: Option<f64>, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub ephemeral: Option<bool>, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub thumbnail_url: Option<String>, +} + +#[derive(Serialize, Deserialize, Clone, Debug, PartialEq)] +#[serde(rename_all = "camelCase")] +pub struct ArchivedMediaEntry { + #[serde(flatten)] + pub metadata: ArchivedMediaMetadata, + pub entry: String, +} + +pub struct ArchiveMediaInput { + pub metadata: ArchivedMediaMetadata, + pub data: Vec<u8>, +} + +pub struct ParsedProjectArchive { + pub project: serde_json::Map<String, serde_json::Value>, + pub media: Vec<ArchivedMediaEntry>, + files: BTreeMap<String, Vec<u8>>, +} + +impl std::fmt::Debug for ParsedProjectArchive { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.debug_struct("ParsedProjectArchive") + .field("project", &self.project) + .field("media", &self.media) + .finish_non_exhaustive() + } +} + +impl ParsedProjectArchive { + pub fn read_media_data(&self, entry: &str) -> Result<&[u8], ProjectArchiveError> { + self.files + .get(entry) + .map(Vec::as_slice) + .ok_or_else(|| { + ProjectArchiveError::new(format!( + "Media file is missing from the archive ({entry})" + )) + }) + } +} + +#[derive(Serialize)] +#[serde(rename_all = "camelCase")] +struct MediaManifest { + version: u32, + media: Vec<ArchivedMediaEntry>, +} + +pub fn sanitize_file_name(name: &str) -> String { + let mut sanitized = String::with_capacity(name.len()); + let mut last_was_underscore = false; + for ch in name.trim().chars() { + let valid = ch.is_ascii_alphanumeric() || matches!(ch, '_' | '.' | '-'); + if valid { + sanitized.push(ch); + last_was_underscore = false; + } else if !last_was_underscore { + sanitized.push('_'); + last_was_underscore = true; + } + } + let trimmed = sanitized.trim_start_matches('.'); + if trimmed.is_empty() { + "file".to_string() + } else { + trimmed.to_string() + } +} + +pub fn build_media_entry_name(asset_id: &str, name: &str) -> String { + format!("{MEDIA_ENTRY_PREFIX}{asset_id}-{}", sanitize_file_name(name)) +} + +pub fn build_project_archive( + project_json: &str, + media: Vec<ArchiveMediaInput>, +) -> Result<Vec<u8>, ProjectArchiveError> { + let manifest: Vec<ArchivedMediaEntry> = media + .iter() + .map(|input| ArchivedMediaEntry { + entry: build_media_entry_name(&input.metadata.id, &input.metadata.name), + metadata: input.metadata.clone(), + }) + .collect(); + + let manifest_json = serde_json::to_string(&MediaManifest { + version: MEDIA_MANIFEST_VERSION, + media: manifest.clone(), + }) + .map_err(|error| ProjectArchiveError::new(error.to_string()))?; + + let mut cursor = Cursor::new(Vec::new()); + { + let mut writer = ZipWriter::new(&mut cursor); + let options = SimpleFileOptions::default() + .compression_method(zip::CompressionMethod::Stored); + let entries: Vec<(&str, &[u8])> = [ + (PROJECT_JSON_ENTRY, project_json.as_bytes()), + (MEDIA_MANIFEST_ENTRY, manifest_json.as_bytes()), + ] + .into_iter() + .chain( + manifest + .iter() + .zip(media.iter()) + .map(|(entry, input)| (entry.entry.as_str(), input.data.as_slice())), + ) + .collect(); + for (name, data) in entries { + writer + .start_file(name, options) + .map_err(|error| ProjectArchiveError::new(error.to_string()))?; + writer + .write_all(data) + .map_err(|error| ProjectArchiveError::new(error.to_string()))?; + } + writer + .finish() + .map_err(|error| ProjectArchiveError::new(error.to_string()))?; + } + Ok(cursor.into_inner()) +} + +pub fn parse_project_archive( + archive: &[u8], +) -> Result<ParsedProjectArchive, ProjectArchiveError> { + let mut zip = ZipArchive::new(Cursor::new(archive)) + .map_err(|_| ProjectArchiveError::new("Not a valid project archive (unreadable zip file)"))?; + + let mut files = BTreeMap::new(); + for index in 0..zip.len() { + let mut file = zip + .by_index(index) + .map_err(|error| ProjectArchiveError::new(error.to_string()))?; + let mut data = Vec::with_capacity(file.size() as usize); + file.read_to_end(&mut data) + .map_err(|error| ProjectArchiveError::new(error.to_string()))?; + files.insert(file.name().to_string(), data); + } + + let project_bytes = files + .get(PROJECT_JSON_ENTRY) + .ok_or_else(|| ProjectArchiveError::new("Not a valid project archive (project.json is missing)"))?; + let project: serde_json::Value = serde_json::from_slice(project_bytes).map_err(|_| { + ProjectArchiveError::new("Not a valid project archive (project.json is not valid JSON)") + })?; + let serde_json::Value::Object(project) = project else { + return Err(ProjectArchiveError::new( + "Not a valid project archive (project.json is not a project)", + )); + }; + + let media = parse_media_manifest(&files)?; + + Ok(ParsedProjectArchive { + project, + media, + files, + }) +} + +fn parse_media_manifest( + files: &BTreeMap<String, Vec<u8>>, +) -> Result<Vec<ArchivedMediaEntry>, ProjectArchiveError> { + let Some(manifest_bytes) = files.get(MEDIA_MANIFEST_ENTRY) else { + if files.keys().any(|name| name.starts_with(MEDIA_ENTRY_PREFIX)) { + return Err(ProjectArchiveError::new( + "Not a valid project archive (media manifest is missing)", + )); + } + return Ok(Vec::new()); + }; + + let parsed: serde_json::Value = serde_json::from_slice(manifest_bytes).map_err(|_| { + ProjectArchiveError::new("Not a valid project archive (media manifest is not valid JSON)") + })?; + let Some(media_values) = parsed.get("media").and_then(serde_json::Value::as_array) else { + return Err(ProjectArchiveError::new( + "Not a valid project archive (media manifest is malformed)", + )); + }; + + media_values + .iter() + .map(|value| parse_manifest_entry(value, files)) + .collect() +} + +fn parse_manifest_entry( + value: &serde_json::Value, + files: &BTreeMap<String, Vec<u8>>, +) -> Result<ArchivedMediaEntry, ProjectArchiveError> { + let invalid = |reason: &str| { + ProjectArchiveError::new(format!( + "Not a valid project archive (media manifest entry {reason})" + )) + }; + + let entry: ArchivedMediaEntry = + serde_json::from_value(value.clone()).map_err(|_| invalid("is malformed"))?; + let metadata = &entry.metadata; + + if metadata.id.is_empty() { + return Err(invalid("has no id")); + } + if !entry.entry.starts_with(MEDIA_ENTRY_PREFIX) || entry.entry.contains("..") { + return Err(invalid(&format!("({}) has an invalid file path", metadata.id))); + } + if !matches!(metadata.media_type.as_str(), "image" | "video" | "audio") { + return Err(invalid(&format!("({}) has an invalid media type", metadata.id))); + } + if metadata.file_type.is_empty() { + return Err(invalid(&format!("({}) has no file type", metadata.id))); + } + if !metadata.last_modified.is_finite() { + return Err(invalid(&format!( + "({}) has no last-modified timestamp", + metadata.id + ))); + } + if !files.contains_key(&entry.entry) { + return Err(ProjectArchiveError::new(format!( + "Not a valid project archive (media file {} is missing)", + entry.entry + ))); + } + + Ok(entry) +} diff --git a/rust/crates/transfer/src/lib.rs b/rust/crates/transfer/src/lib.rs new file mode 100644 index 00000000..a541e485 --- /dev/null +++ b/rust/crates/transfer/src/lib.rs @@ -0,0 +1,8 @@ +mod container; + +pub use container::{ + ArchivedMediaEntry, ArchivedMediaMetadata, ArchiveMediaInput, MEDIA_ENTRY_PREFIX, + MEDIA_MANIFEST_ENTRY, PROJECT_ARCHIVE_EXTENSION, PROJECT_JSON_ENTRY, ParsedProjectArchive, + ProjectArchiveError, build_media_entry_name, build_project_archive, parse_project_archive, + sanitize_file_name, +}; diff --git a/rust/crates/transfer/tests/container.rs b/rust/crates/transfer/tests/container.rs new file mode 100644 index 00000000..f05ef329 --- /dev/null +++ b/rust/crates/transfer/tests/container.rs @@ -0,0 +1,95 @@ +use std::io::Write as _; + +use transfer::*; + +fn sample_media() -> Vec<ArchiveMediaInput> { + vec![ + ArchiveMediaInput { + metadata: ArchivedMediaMetadata { + id: "asset-1".into(), + name: "clip one.mp4".into(), + media_type: "video".into(), + file_type: "video/mp4".into(), + last_modified: 1_753_000_000_000.0, + width: Some(1920.0), + height: Some(1080.0), + duration: Some(12.5), + ephemeral: None, + thumbnail_url: None, + }, + data: vec![1, 2, 3, 4], + }, + ArchiveMediaInput { + metadata: ArchivedMediaMetadata { + id: "asset-2".into(), + name: ".hidden".into(), + media_type: "audio".into(), + file_type: "audio/wav".into(), + last_modified: 1_753_000_000_001.0, + width: None, + height: None, + duration: None, + ephemeral: Some(true), + thumbnail_url: None, + }, + data: vec![5, 6, 7], + }, + ] +} + +#[test] +fn sanitize_matches_web_rules() { + assert_eq!(sanitize_file_name("clip one.mp4"), "clip_one.mp4"); + assert_eq!(sanitize_file_name(".."), "file"); + assert_eq!(sanitize_file_name("...weird..name.."), "weird..name.."); + assert_eq!(sanitize_file_name("a b"), "a_b"); + assert_eq!(sanitize_file_name(" "), "file"); + assert_eq!(sanitize_file_name("caf\u{00e9}.mov"), "caf_.mov"); +} + +#[test] +fn archive_round_trip() { + let project_json = r#"{"version":7,"metadata":{"id":"p1"}}"#; + let archive = build_project_archive(project_json, sample_media()).expect("build"); + let parsed = parse_project_archive(&archive).expect("parse"); + + assert_eq!( + parsed.project.get("version").and_then(|v| v.as_u64()), + Some(7) + ); + assert_eq!(parsed.media.len(), 2); + assert_eq!(parsed.media[0].entry, "media/asset-1-clip_one.mp4"); + assert_eq!(parsed.media[1].entry, "media/asset-2-hidden"); + assert_eq!( + parsed.read_media_data("media/asset-1-clip_one.mp4").unwrap(), + &[1, 2, 3, 4] + ); + assert_eq!(parsed.read_media_data("media/asset-2-hidden").unwrap(), &[5, 6, 7]); +} + +#[test] +fn archive_without_media_is_valid() { + let archive = build_project_archive("{}", Vec::new()).expect("build"); + let parsed = parse_project_archive(&archive).expect("parse"); + assert!(parsed.media.is_empty()); +} + +#[test] +fn rejects_garbage() { + let error = parse_project_archive(b"not a zip").unwrap_err(); + assert!(error.to_string().contains("unreadable zip file")); +} + +#[test] +fn rejects_missing_project_json() { + let mut cursor = std::io::Cursor::new(Vec::new()); + { + let mut writer = zip::ZipWriter::new(&mut cursor); + let options: zip::write::SimpleFileOptions = Default::default(); + writer.start_file("other.txt", options).unwrap(); + writer.write_all(b"hi").unwrap(); + writer.finish().unwrap(); + } + let error = parse_project_archive(&cursor.into_inner()).unwrap_err(); + assert!(error.to_string().contains("project.json is missing")); +} diff --git a/rust/crates/transfer/tests/cross_compat.rs b/rust/crates/transfer/tests/cross_compat.rs new file mode 100644 index 00000000..02e53ac6 --- /dev/null +++ b/rust/crates/transfer/tests/cross_compat.rs @@ -0,0 +1,56 @@ +use transfer::*; + +#[test] +fn parses_web_built_archive() { + let archive = std::fs::read(concat!(env!("CARGO_MANIFEST_DIR"), "/tests/fixtures/web-built.ocp")) + .expect("web-built fixture"); + let parsed = parse_project_archive(&archive).expect("parse web archive"); + + assert_eq!( + parsed + .project + .get("nested") + .and_then(|n| n.get("value")) + .and_then(|v| v.as_u64()), + Some(42) + ); + assert_eq!(parsed.media.len(), 1); + assert_eq!(parsed.media[0].entry, "media/web-asset-web_clip.mov"); + assert_eq!(parsed.media[0].metadata.media_type, "video"); + assert_eq!(parsed.media[0].metadata.width, Some(1280.0)); + assert_eq!( + parsed.read_media_data("media/web-asset-web_clip.mov").unwrap(), + &[9, 8, 7, 6] + ); +} + +#[test] +fn writes_rust_built_archive_for_web_parse() { + let archive = build_project_archive( + r#"{"version":7,"metadata":{"id":"rust-built"}}"#, + vec![ArchiveMediaInput { + metadata: ArchivedMediaMetadata { + id: "rust-asset".into(), + name: "rust audio.mp3".into(), + media_type: "audio".into(), + file_type: "audio/mpeg".into(), + last_modified: 1_753_000_000_000.0, + width: None, + height: None, + duration: Some(1.5), + ephemeral: None, + thumbnail_url: None, + }, + data: vec![4, 3, 2, 1], + }], + ) + .expect("build"); + let parsed = parse_project_archive(&archive).expect("re-parse"); + assert_eq!(parsed.media[0].entry, "media/rust-asset-rust_audio.mp3"); + assert_eq!( + parsed + .read_media_data("media/rust-asset-rust_audio.mp3") + .unwrap(), + &[4, 3, 2, 1] + ); +} diff --git a/rust/crates/transfer/tests/fixtures/web-built.ocp b/rust/crates/transfer/tests/fixtures/web-built.ocp Binary files differnew file mode 100644 index 00000000..473897de --- /dev/null +++ b/rust/crates/transfer/tests/fixtures/web-built.ocp |
