diff options
Diffstat (limited to 'rust/crates/scene')
| -rw-r--r-- | rust/crates/scene/Cargo.toml | 10 | ||||
| -rw-r--r-- | rust/crates/scene/src/animation.rs | 127 | ||||
| -rw-r--r-- | rust/crates/scene/src/effect.rs | 13 | ||||
| -rw-r--r-- | rust/crates/scene/src/element.rs | 187 | ||||
| -rw-r--r-- | rust/crates/scene/src/iso_date.rs | 19 | ||||
| -rw-r--r-- | rust/crates/scene/src/lib.rs | 18 | ||||
| -rw-r--r-- | rust/crates/scene/src/mask.rs | 128 | ||||
| -rw-r--r-- | rust/crates/scene/src/params.rs | 12 | ||||
| -rw-r--r-- | rust/crates/scene/src/project.rs | 76 | ||||
| -rw-r--r-- | rust/crates/scene/src/scene.rs | 33 | ||||
| -rw-r--r-- | rust/crates/scene/src/track.rs | 88 | ||||
| -rw-r--r-- | rust/crates/scene/tests/round_trip.rs | 190 |
12 files changed, 901 insertions, 0 deletions
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)); +} |
