filmcraft/crates/project/src/lib.rs

1380 lines
50 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

//! The FilmCraft document model.
//!
//! A [`Project`] owns a tree of bins and a flat map of [`ProjectItem`]s (media clips, sequences,
//! synthetic items). A [`Sequence`] has video and audio [`Track`]s holding [`TrackItem`]s (clip
//! instances) and [`Transition`]s. Everything is plain serde data; the engine wraps the project in an
//! `Arc` and edits copy-on-write, so undo snapshots and background readers are free.
//!
//! Time: timeline positions are sequence ticks; `source_in` is media time.
pub mod caption;
pub mod effect;
pub mod essential;
pub mod graphic;
pub mod keyframe;
pub mod mask;
pub mod mixer;
pub mod multicam;
pub mod transcript;
pub mod vtransition;
use std::collections::BTreeMap;
use filmcraft_geom::Vec2;
use filmcraft_media::{Generator, MediaInfo};
use filmcraft_time::{FrameRate, TICKS_PER_SECOND, Tick, TimeRange};
use serde::{Deserialize, Serialize};
pub use caption::{Caption, CaptionAlign, CaptionAnchor, CaptionFormat, CaptionStyle, CaptionTrack, plain_text};
pub use effect::{EffectDef, EffectInstance, EffectKind, ParamDef, ParamKind, effect_defs, find_effect};
pub use essential::{AudioType, EssentialSound};
pub use keyframe::{Interpolation, Keyframe, Param, ParamValue};
pub use mask::{Mask, MaskMode, MaskPath, MaskVertex, TrackMethod};
pub use mixer::{AutomationMode, InputMap, MixerStrip, TrackSend};
pub use multicam::{Camera, MergedClip, MulticamAudio, MulticamSel, MulticamSource};
pub use transcript::{Speaker, Transcript, Word};
macro_rules! id_type {
($name:ident) => {
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)]
#[serde(transparent)]
pub struct $name(pub u64);
};
}
id_type!(ItemId);
id_type!(BinId);
id_type!(TrackId);
id_type!(ClipId);
id_type!(TransitionId);
id_type!(MarkerId);
/// Premiere-style label colours (names from the Label menu; values are our own).
#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, Serialize, Deserialize)]
pub enum Label {
Violet,
Iris,
Caribbean,
Lavender,
Cerulean,
Forest,
Rose,
Mango,
Purple,
Blue,
Teal,
Magenta,
Tan,
Green,
Brown,
Yellow,
}
impl Label {
pub const ALL: [Label; 16] = [
Label::Violet,
Label::Iris,
Label::Caribbean,
Label::Lavender,
Label::Cerulean,
Label::Forest,
Label::Rose,
Label::Mango,
Label::Purple,
Label::Blue,
Label::Teal,
Label::Magenta,
Label::Tan,
Label::Green,
Label::Brown,
Label::Yellow,
];
pub fn name(self) -> &'static str {
match self {
Label::Violet => "Violet",
Label::Iris => "Iris",
Label::Caribbean => "Caribbean",
Label::Lavender => "Lavender",
Label::Cerulean => "Cerulean",
Label::Forest => "Forest",
Label::Rose => "Rose",
Label::Mango => "Mango",
Label::Purple => "Purple",
Label::Blue => "Blue",
Label::Teal => "Teal",
Label::Magenta => "Magenta",
Label::Tan => "Tan",
Label::Green => "Green",
Label::Brown => "Brown",
Label::Yellow => "Yellow",
}
}
/// Label colours (Premiere 26 Spectrum defaults, measured; see plan/premiere/02-ui-ux.md §1.5).
pub fn rgb(self) -> [u8; 3] {
match self {
Label::Violet => [0x38, 0x0e, 0xa7],
Label::Iris => [0x1d, 0x4a, 0x64],
Label::Caribbean => [0x35, 0x54, 0x18],
Label::Lavender => [0x6b, 0x1c, 0x82],
Label::Cerulean => [0x23, 0x53, 0x5a],
Label::Forest => [0x40, 0x4a, 0x11],
Label::Rose => [0x80, 0x18, 0x36],
Label::Mango => [0x80, 0x3f, 0x17],
Label::Purple => [0x59, 0x0d, 0xb0],
Label::Blue => [0x19, 0x2d, 0x94],
Label::Teal => [0x1f, 0x4d, 0x45],
Label::Magenta => [0x79, 0x1c, 0x56],
Label::Tan => [0x6c, 0x5b, 0x47],
Label::Green => [0x29, 0x5c, 0x2d],
Label::Brown => [0x58, 0x3d, 0x14],
Label::Yellow => [0x6e, 0x66, 0x28],
}
}
/// Marker colours (Markers panel chips).
pub fn marker_rgb(self) -> [u8; 3] {
match self {
Label::Rose | Label::Magenta => [0xc1, 0x3c, 0x3d],
Label::Purple | Label::Violet | Label::Lavender => [0xa9, 0x8c, 0xaf],
Label::Mango | Label::Brown | Label::Tan => [0xda, 0x76, 0x39],
Label::Yellow => [0xc9, 0xa3, 0x44],
Label::Blue | Label::Iris | Label::Cerulean => [0x56, 0x8b, 0xf4],
Label::Teal | Label::Caribbean => [0x72, 0xf0, 0xd7],
_ => [0x75, 0x85, 0x42],
}
}
pub fn from_name(s: &str) -> Option<Label> {
Self::ALL.iter().copied().find(|l| l.name().eq_ignore_ascii_case(s))
}
}
/// Where an item's pixels/samples come from.
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
pub enum MediaRef {
/// A file on disk (native) or a named blob (web).
File { path: String },
/// A synthetic generator (Bars and Tone, Color Matte, demo footage…).
Generator(Generator),
}
/// Overrides applied when interpreting footage (Modify ▸ Interpret Footage).
#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)]
pub struct Interpretation {
pub frame_rate: Option<FrameRate>,
pub par: Option<(u32, u32)>,
pub ignore_alpha: bool,
pub invert_alpha: bool,
/// Color Management ▸ override the colour space detected from the file's metadata.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub color_space: Option<filmcraft_color::ColorSpace>,
/// Modify ▸ Audio Channels: how the source channels map to the audio clips made when the item
/// is edited into a sequence. None = one stereo clip of the first two channels.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub audio_channels: Option<AudioChannelMap>,
}
/// Modify ▸ Audio Channels: the clip channel format and, per audio clip, the source channels it
/// plays (0-based). A mono clip has one channel (played on both sides); a stereo clip two.
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct AudioChannelMap {
pub format: AudioChannels,
pub clips: Vec<Vec<u16>>,
}
impl AudioChannelMap {
/// The default mapping of a source with `channels` channels in `format`: mono = one clip per
/// channel, stereo = channel pairs, 5.1 / adaptive = one clip with every channel.
pub fn for_format(format: AudioChannels, channels: u16) -> Self {
let n = channels.max(1);
let clips = match format {
AudioChannels::Mono => (0..n).map(|c| vec![c]).collect(),
AudioChannels::Stereo => (0..n).step_by(2).map(|c| if c + 1 < n { vec![c, c + 1] } else { vec![c] }).collect(),
AudioChannels::Surround51 | AudioChannels::Adaptive => vec![(0..n).collect()],
};
Self { format, clips }
}
}
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
pub struct MediaClip {
pub media: MediaRef,
pub info: MediaInfo,
pub interpret: Interpretation,
/// Source in/out marks (media time).
pub mark_in: Option<Tick>,
pub mark_out: Option<Tick>,
pub markers: Vec<Marker>,
/// Made offline on purpose (Make Offline / Offline All): renders the offline slate even if
/// the file exists. Files that are merely missing are detected at run time instead (the
/// engine's media pool), so a project whose media comes back needs no change.
pub offline: bool,
/// Proxy media, if attached.
pub proxy: Option<MediaRef>,
/// Size and content fingerprint of the file when it was imported (or last linked); relinking
/// checks a candidate against it. None for generators and projects from older builds.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub identity: Option<MediaIdentity>,
}
/// What a media file looked like when it was linked: its size plus a fast content fingerprint
/// (a 64-bit hash of the size, the first MiB and the last MiB). Cheap to compute on any file and
/// enough to tell a moved original from a different file with the same name.
#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, Serialize, Deserialize)]
pub struct MediaIdentity {
pub size: u64,
/// Hex-encoded in JSON (`"9f3c…"`), so the value survives JavaScript readers.
#[serde(with = "hex_u64")]
pub fingerprint: u64,
}
mod hex_u64 {
use serde::{Deserialize, Deserializer, Serializer};
pub fn serialize<S: Serializer>(v: &u64, s: S) -> Result<S::Ok, S::Error> {
s.serialize_str(&format!("{v:016x}"))
}
pub fn deserialize<'de, D: Deserializer<'de>>(d: D) -> Result<u64, D::Error> {
let s = String::deserialize(d)?;
u64::from_str_radix(&s, 16).map_err(serde::de::Error::custom)
}
}
impl MediaClip {
pub fn frame_rate(&self) -> FrameRate {
self.interpret.frame_rate.unwrap_or_else(|| self.info.frame_rate())
}
pub fn duration(&self) -> Tick {
self.info.duration
}
}
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
#[allow(clippy::large_enum_variant)]
pub enum ItemKind {
Media(MediaClip),
Sequence(Box<Sequence>),
/// A subclip: a media item restricted to a range.
Subclip {
parent: ItemId,
range: TimeRange,
/// Make Subclip ▸ Restrict Trims To Subclip Boundaries: clips of the subclip can't be
/// trimmed past its end (otherwise the parent's media is the limit).
#[serde(default)]
restrict_trims: bool,
},
AdjustmentLayer {
width: u32,
height: u32,
rate: FrameRate,
duration: Tick,
},
/// The source of graphic clips: a transparent canvas of the sequence frame size. The clip's
/// text and shape layers are effect instances on the track item (see [`graphic`]). Not shown
/// in the Project panel; unlimited duration.
Graphic {
width: u32,
height: u32,
rate: FrameRate,
},
}
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
pub struct ProjectItem {
pub id: ItemId,
pub name: String,
pub label: Label,
pub kind: ItemKind,
/// Free-form metadata (Description, Scene, Shot, Log Note…).
#[serde(default)]
pub metadata: BTreeMap<String, String>,
/// Import time counter (for "Date Created"/sorting), not wall clock.
#[serde(default)]
pub created: u64,
/// Split In/Out points set in the Source monitor (Markers ▸ Mark Split); they override the
/// item's In/Out for one channel (J- and L-cuts).
#[serde(default, skip_serializing_if = "SplitMarks::is_empty")]
pub split: SplitMarks,
}
impl ProjectItem {
pub fn as_media(&self) -> Option<&MediaClip> {
match &self.kind {
ItemKind::Media(m) => Some(m),
_ => None,
}
}
pub fn as_media_mut(&mut self) -> Option<&mut MediaClip> {
match &mut self.kind {
ItemKind::Media(m) => Some(m),
_ => None,
}
}
pub fn as_sequence(&self) -> Option<&Sequence> {
match &self.kind {
ItemKind::Sequence(s) => Some(s),
_ => None,
}
}
pub fn as_sequence_mut(&mut self) -> Option<&mut Sequence> {
match &mut self.kind {
ItemKind::Sequence(s) => Some(s),
_ => None,
}
}
pub fn has_video(&self) -> bool {
match &self.kind {
ItemKind::Media(m) => m.info.has_video(),
ItemKind::Sequence(_) | ItemKind::AdjustmentLayer { .. } | ItemKind::Graphic { .. } => true,
ItemKind::Subclip { .. } => true,
}
}
pub fn has_audio(&self) -> bool {
match &self.kind {
ItemKind::Media(m) => m.info.has_audio(),
ItemKind::Sequence(s) => !s.audio_tracks.is_empty(),
_ => false,
}
}
pub fn duration(&self) -> Tick {
match &self.kind {
ItemKind::Media(m) => m.duration(),
ItemKind::Sequence(s) => s.duration(),
ItemKind::Subclip { range, .. } => range.duration,
ItemKind::AdjustmentLayer { duration, .. } => *duration,
ItemKind::Graphic { .. } => Tick(3600 * TICKS_PER_SECOND),
}
}
pub fn frame_rate(&self) -> FrameRate {
match &self.kind {
ItemKind::Media(m) => m.frame_rate(),
ItemKind::Sequence(s) => s.settings.frame_rate,
ItemKind::AdjustmentLayer { rate, .. } | ItemKind::Graphic { rate, .. } => *rate,
ItemKind::Subclip { .. } => FrameRate::default(),
}
}
/// Human-readable media type for the Project panel "Media Type" column.
pub fn type_label(&self) -> &'static str {
match &self.kind {
ItemKind::Media(m) => match m.info.kind {
filmcraft_media::MediaKind::Movie => "Movie",
filmcraft_media::MediaKind::AudioOnly => "Audio",
filmcraft_media::MediaKind::Still => "Still Image",
filmcraft_media::MediaKind::ImageSequence => "Image Sequence",
filmcraft_media::MediaKind::Synthetic => "Synthetic",
},
ItemKind::Sequence(s) if s.multicam.is_some() => "Multi-Camera Source Sequence",
ItemKind::Sequence(s) if s.merged.is_some() => "Merged Clip",
ItemKind::Sequence(_) => "Sequence",
ItemKind::Subclip { .. } => "Subclip",
ItemKind::AdjustmentLayer { .. } => "Adjustment Layer",
ItemKind::Graphic { .. } => "Graphic",
}
}
}
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
pub enum BinEntry {
Item(ItemId),
Bin(Bin),
}
#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)]
pub struct Bin {
pub id: BinId,
pub name: String,
pub children: Vec<BinEntry>,
}
impl Bin {
pub fn find_bin_mut(&mut self, id: BinId) -> Option<&mut Bin> {
if self.id == id {
return Some(self);
}
for c in &mut self.children {
if let BinEntry::Bin(b) = c
&& let Some(f) = b.find_bin_mut(id)
{
return Some(f);
}
}
None
}
pub fn find_bin(&self, id: BinId) -> Option<&Bin> {
if self.id == id {
return Some(self);
}
self.children.iter().find_map(|c| if let BinEntry::Bin(b) = c { b.find_bin(id) } else { None })
}
/// Remove an item anywhere in the tree.
pub fn remove_item(&mut self, id: ItemId) -> bool {
let before = self.children.len();
self.children.retain(|c| !matches!(c, BinEntry::Item(i) if *i == id));
if self.children.len() != before {
return true;
}
self.children.iter_mut().any(|c| if let BinEntry::Bin(b) = c { b.remove_item(id) } else { false })
}
/// All items in this bin and sub-bins.
pub fn all_items(&self, out: &mut Vec<ItemId>) {
for c in &self.children {
match c {
BinEntry::Item(i) => out.push(*i),
BinEntry::Bin(b) => b.all_items(out),
}
}
}
/// The bin containing an item.
pub fn parent_of(&self, id: ItemId) -> Option<BinId> {
for c in &self.children {
match c {
BinEntry::Item(i) if *i == id => return Some(self.id),
BinEntry::Bin(b) => {
if let Some(p) = b.parent_of(id) {
return Some(p);
}
}
_ => {}
}
}
None
}
}
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
pub enum MarkerKind {
#[default]
Comment,
Chapter,
Segmentation,
WebLink,
}
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
pub struct Marker {
pub id: MarkerId,
pub start: Tick,
pub duration: Tick,
pub name: String,
pub comment: String,
pub kind: MarkerKind,
pub color: Label,
}
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
pub enum TrackKind {
#[default]
Video,
Audio,
}
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
pub enum AudioChannels {
Mono,
#[default]
Stereo,
Surround51,
Adaptive,
}
/// A clip instance on a track.
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
pub struct TrackItem {
pub id: ClipId,
pub item: ItemId,
pub name: String,
pub label: Label,
/// Timeline position (sequence ticks).
pub start: Tick,
/// Duration on the timeline.
pub duration: Tick,
/// Media time of the first frame shown.
pub source_in: Tick,
/// Playback speed (1.0 = 100%). Negative = reverse.
pub speed: f64,
#[serde(default)]
pub reverse: bool,
pub enabled: bool,
/// Linked partner items (video ↔ audio of the same source).
#[serde(default)]
pub link: Option<u64>,
#[serde(default)]
pub group: Option<u64>,
pub effects: Vec<EffectInstance>,
#[serde(default)]
pub markers: Vec<Marker>,
/// Clip gain in dB (Audio Gain dialog), separate from the Volume effect.
#[serde(default)]
pub gain_db: f64,
/// Frame hold: media time frozen (Frame Hold Options).
#[serde(default)]
pub frame_hold: Option<Tick>,
/// Scale to frame size (Set to Frame Size / Scale to Frame Size).
#[serde(default)]
pub scale_to_frame: bool,
/// Essential Sound audio type and settings (audio clips).
#[serde(default, skip_serializing_if = "Option::is_none")]
pub essential: Option<EssentialSound>,
/// Multi-camera clip (a nested multi-camera source sequence): enabled flag and angle.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub multicam: Option<MulticamSel>,
/// Video Options ▸ Time Interpolation: how frames are made when the clip's speed is changed.
#[serde(default, skip_serializing_if = "TimeInterpolation::is_default")]
pub time_interpolation: TimeInterpolation,
/// Frame Hold Options ▸ Hold Filters: effects are evaluated at the held frame's time instead
/// of animating through the hold.
#[serde(default, skip_serializing_if = "std::ops::Not::not")]
pub hold_filters: bool,
/// Video Options ▸ Field Options… (stored; FilmCraft renders progressive frames).
#[serde(default, skip_serializing_if = "Option::is_none")]
pub field_options: Option<FieldOptions>,
/// Audio clips: the source channels this clip plays (0-based; one = mono on both sides, two =
/// left/right). Empty = the first two channels (mono sources on both sides).
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub source_channels: Vec<u16>,
}
/// Time interpolation for speed-changed clips (Clip ▸ Video Options ▸ Time Interpolation).
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Hash, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub enum TimeInterpolation {
/// Show the nearest earlier source frame (repeats or drops frames).
#[default]
FrameSampling,
/// Cross-fade the two source frames around the exact media time.
FrameBlending,
/// Motion-compensated in-between frames. Rendered as frame blending for now.
OpticalFlow,
}
impl TimeInterpolation {
pub const ALL: [TimeInterpolation; 3] = [TimeInterpolation::FrameSampling, TimeInterpolation::FrameBlending, TimeInterpolation::OpticalFlow];
pub fn is_default(&self) -> bool {
*self == TimeInterpolation::FrameSampling
}
pub fn name(self) -> &'static str {
match self {
TimeInterpolation::FrameSampling => "frameSampling",
TimeInterpolation::FrameBlending => "frameBlending",
TimeInterpolation::OpticalFlow => "opticalFlow",
}
}
pub fn label(self) -> &'static str {
match self {
TimeInterpolation::FrameSampling => "Frame Sampling",
TimeInterpolation::FrameBlending => "Frame Blending",
TimeInterpolation::OpticalFlow => "Optical Flow",
}
}
pub fn from_name(s: &str) -> Option<Self> {
Self::ALL.into_iter().find(|t| t.name().eq_ignore_ascii_case(s) || t.label().eq_ignore_ascii_case(s))
}
}
/// Field Options… (stored with the clip).
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct FieldOptions {
pub reverse_field_dominance: bool,
pub processing: FieldProcessing,
}
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub enum FieldProcessing {
#[default]
None,
AlwaysDeinterlace,
FlickerRemoval,
}
impl TrackItem {
pub fn end(&self) -> Tick {
self.start + self.duration
}
pub fn range(&self) -> TimeRange {
TimeRange::new(self.start, self.duration)
}
/// Media time displayed at timeline time `t` (ignores time remapping keyframes).
pub fn source_time_at(&self, t: Tick) -> Tick {
if let Some(h) = self.frame_hold {
return h;
}
self.moving_source_time_at(t)
}
/// Media time at which effects and intrinsic attributes are evaluated at timeline time `t`: a
/// frame hold freezes them only with Hold Filters on; otherwise they animate through the hold.
pub fn effect_time_at(&self, t: Tick) -> Tick {
match self.frame_hold {
Some(h) if self.hold_filters => h,
Some(_) => self.moving_source_time_at(t),
None => self.source_time_at(t),
}
}
/// Media time at timeline time `t` ignoring any frame hold.
pub fn moving_source_time_at(&self, t: Tick) -> Tick {
let rel = t - self.start;
let scaled = Tick((rel.0 as f64 * self.speed.abs()).round() as i64);
if self.reverse {
self.source_in + Tick((self.duration.0 as f64 * self.speed.abs()).round() as i64) - scaled - Tick(1)
} else {
self.source_in + scaled
}
}
/// Media time consumed by the item (the source out point).
pub fn source_out(&self) -> Tick {
self.source_in + Tick((self.duration.0 as f64 * self.speed.abs()).round() as i64)
}
pub fn effect(&self, id: &str) -> Option<&EffectInstance> {
self.effects.iter().find(|e| e.effect == id)
}
pub fn effect_mut(&mut self, id: &str) -> Option<&mut EffectInstance> {
self.effects.iter_mut().find(|e| e.effect == id)
}
/// Standard (non-intrinsic) effects applied, for the fx badge.
/// The Opacity effect has masks (the layer is cut out before Motion).
pub fn has_opacity_masks(&self) -> bool {
self.effect("opacity").is_some_and(|e| e.enabled && e.masks.iter().any(|m| m.mode != mask::MaskMode::None))
}
pub fn has_standard_effects(&self) -> bool {
self.effects.iter().any(|e| e.def().is_some_and(|d| !d.intrinsic) && !graphic::is_layer(e))
}
/// The graphic layers (text / shape) of a graphic clip, in paint order (first = back).
pub fn graphic_layers(&self) -> impl Iterator<Item = &EffectInstance> {
self.effects.iter().filter(|e| graphic::is_layer(e))
}
pub fn has_modified_intrinsics(&self) -> bool {
self.effects.iter().any(|e| {
e.def().is_some_and(|d| d.intrinsic)
&& (e.is_animated()
|| !e.masks.is_empty()
|| e.def().is_some_and(|d| d.params.iter().any(|p| e.params.get(p.id).is_some_and(|v| !param_eq_default(&v.value, &p.default)))))
})
}
}
fn param_eq_default(v: &ParamValue, d: &ParamValue) -> bool {
match (v, d) {
(ParamValue::Vec2(_), ParamValue::Vec2(dd)) if dd.x.is_nan() => true,
_ => v == d,
}
}
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
pub enum TransitionAlign {
#[default]
CenterAtCut,
StartAtCut,
EndAtCut,
}
/// A transition on a track. `at` is the cut point (or clip edge for single-sided transitions).
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
pub struct Transition {
pub id: TransitionId,
pub effect: EffectInstance,
/// Timeline range covered by the transition.
pub start: Tick,
pub duration: Tick,
/// Outgoing clip (left) and incoming clip (right); one may be None (fade from/to nothing).
pub from: Option<ClipId>,
pub to: Option<ClipId>,
pub align: TransitionAlign,
#[serde(default)]
pub reverse: bool,
}
impl Transition {
pub fn end(&self) -> Tick {
self.start + self.duration
}
pub fn range(&self) -> TimeRange {
TimeRange::new(self.start, self.duration)
}
/// Progress 0..1 at timeline `t`.
pub fn progress(&self, t: Tick) -> f64 {
if self.duration.0 <= 0 {
return 1.0;
}
(((t - self.start).0 as f64) / self.duration.0 as f64).clamp(0.0, 1.0)
}
}
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
pub struct Track {
pub id: TrackId,
pub kind: TrackKind,
pub name: String,
pub items: Vec<TrackItem>,
pub transitions: Vec<Transition>,
pub locked: bool,
pub sync_lock: bool,
/// Video: eye (output) toggle. Audio: not muted.
pub enabled: bool,
pub muted: bool,
pub solo: bool,
/// Audio track channel format.
pub channels: AudioChannels,
/// Audio track volume (dB) and pan (-100..100) for the Track Mixer.
pub volume_db: f64,
pub pan: f64,
/// Track-level audio effects (mixer inserts, slots 1–5; `EffectInstance::post_fader` picks the side).
#[serde(default)]
pub effects: Vec<EffectInstance>,
/// Audio Track Mixer state: automation mode and lanes, sends, output, record arm.
#[serde(default)]
pub mixer: MixerStrip,
}
impl Track {
pub fn new(id: TrackId, kind: TrackKind, name: String) -> Self {
Self {
id,
kind,
name,
items: Vec::new(),
transitions: Vec::new(),
locked: false,
sync_lock: true,
enabled: true,
muted: false,
solo: false,
channels: AudioChannels::Stereo,
volume_db: 0.0,
pan: 0.0,
effects: Vec::new(),
mixer: MixerStrip::default(),
}
}
pub fn end(&self) -> Tick {
self.items.iter().map(|i| i.end()).max().unwrap_or(Tick::ZERO)
}
/// Item covering time `t`.
pub fn item_at(&self, t: Tick) -> Option<&TrackItem> {
// items are sorted by start
let idx = self.items.partition_point(|i| i.start <= t);
idx.checked_sub(1).map(|i| &self.items[i]).filter(|i| t < i.end())
}
pub fn item(&self, id: ClipId) -> Option<&TrackItem> {
self.items.iter().find(|i| i.id == id)
}
pub fn item_mut(&mut self, id: ClipId) -> Option<&mut TrackItem> {
self.items.iter_mut().find(|i| i.id == id)
}
pub fn sort(&mut self) {
self.items.sort_by_key(|i| i.start);
self.transitions.sort_by_key(|t| t.start);
}
/// Invariant check: items do not overlap.
pub fn check(&self) -> Result<(), String> {
for w in self.items.windows(2) {
if w[0].end() > w[1].start {
return Err(format!("{}: items {:?} and {:?} overlap", self.name, w[0].id, w[1].id));
}
}
for i in &self.items {
if i.duration.0 <= 0 {
return Err(format!("{}: item {:?} has non-positive duration", self.name, i.id));
}
}
Ok(())
}
}
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
pub struct SequenceSettings {
pub width: u32,
pub height: u32,
pub frame_rate: FrameRate,
pub par: (u32, u32),
pub sample_rate: u32,
pub drop_frame: bool,
/// Editing mode / preset name shown in the settings dialog.
pub preset: String,
pub audio_master: AudioChannels,
/// Preview file codec name.
pub preview_codec: String,
pub max_bit_depth: bool,
pub max_render_quality: bool,
/// Working colour space (display label; [`SequenceSettings::color`] is authoritative).
pub working_space: String,
/// Colour pipeline: working space (Rec. 709 / Rec. 2100 PQ / HLG), wide-gamut compositing,
/// Auto Tone Map Media.
#[serde(default = "default_color_pipeline")]
pub color: filmcraft_color::ColorPipeline,
}
fn default_color_pipeline() -> filmcraft_color::ColorPipeline {
filmcraft_color::ColorPipeline::REC709
}
impl Default for SequenceSettings {
fn default() -> Self {
Self {
width: 1920,
height: 1080,
frame_rate: FrameRate::FPS_23_976,
par: (1, 1),
sample_rate: 48_000,
drop_frame: false,
preset: "HD 1080p 23.976".into(),
audio_master: AudioChannels::Stereo,
preview_codec: "ProRes 422".into(),
max_bit_depth: false,
max_render_quality: false,
working_space: "Rec. 709".into(),
color: filmcraft_color::ColorPipeline::REC709,
}
}
}
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
pub struct Sequence {
pub settings: SequenceSettings,
pub video_tracks: Vec<Track>,
pub audio_tracks: Vec<Track>,
pub markers: Vec<Marker>,
pub mark_in: Option<Tick>,
pub mark_out: Option<Tick>,
/// Work area bar (optional; Premiere hides it by default now).
pub work_area: Option<TimeRange>,
/// Timecode of the first frame (frames), "Start Time" in Sequence settings.
pub start_timecode: i64,
/// Master audio volume (dB).
#[serde(default)]
pub master_volume_db: f64,
/// Mix track inserts (`EffectInstance::post_fader` picks the side).
#[serde(default)]
pub master_effects: Vec<EffectInstance>,
/// Mix track automation (`volume` lane) and mode.
#[serde(default)]
pub master_mixer: MixerStrip,
/// Audio submix tracks (no clips); tracks and sends route into them, they route to the Mix
/// or to a submix after them.
#[serde(default)]
pub submix_tracks: Vec<Track>,
/// Caption tracks (drawn above the video tracks; first = top). Older files have none (serde default).
#[serde(default)]
pub caption_tracks: Vec<CaptionTrack>,
/// Set on multi-camera source sequences (cameras, audio mode).
#[serde(default, skip_serializing_if = "Option::is_none")]
pub multicam: Option<MulticamSource>,
/// Set on merged clips (the merged video and audio items).
#[serde(default, skip_serializing_if = "Option::is_none")]
pub merged: Option<MergedClip>,
/// Split In/Out points (Markers ▸ Mark Split): per-channel overrides of `mark_in`/`mark_out`.
#[serde(default, skip_serializing_if = "SplitMarks::is_empty")]
pub split: SplitMarks,
}
/// Split edit points: separate video and audio In/Out points (Markers ▸ Mark Split). `None` means
/// the channel uses the ordinary In/Out point.
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
#[serde(default, rename_all = "camelCase")]
pub struct SplitMarks {
pub video_in: Option<Tick>,
pub video_out: Option<Tick>,
pub audio_in: Option<Tick>,
pub audio_out: Option<Tick>,
}
impl SplitMarks {
pub fn is_empty(&self) -> bool {
*self == Self::default()
}
/// The video In point given the ordinary In point.
pub fn video_in_or(&self, mark_in: Option<Tick>) -> Option<Tick> {
self.video_in.or(mark_in)
}
pub fn video_out_or(&self, mark_out: Option<Tick>) -> Option<Tick> {
self.video_out.or(mark_out)
}
pub fn audio_in_or(&self, mark_in: Option<Tick>) -> Option<Tick> {
self.audio_in.or(mark_in)
}
pub fn audio_out_or(&self, mark_out: Option<Tick>) -> Option<Tick> {
self.audio_out.or(mark_out)
}
}
impl Sequence {
pub fn duration(&self) -> Tick {
let media = self.video_tracks.iter().chain(&self.audio_tracks).map(Track::end).max().unwrap_or(Tick::ZERO);
media.max(self.caption_tracks.iter().map(CaptionTrack::end).max().unwrap_or(Tick::ZERO))
}
pub fn caption_track(&self, id: TrackId) -> Option<&CaptionTrack> {
self.caption_tracks.iter().find(|t| t.id == id)
}
pub fn caption_track_mut(&mut self, id: TrackId) -> Option<&mut CaptionTrack> {
self.caption_tracks.iter_mut().find(|t| t.id == id)
}
/// Find a caption anywhere: (caption track id, caption).
pub fn find_caption(&self, id: ClipId) -> Option<(TrackId, &Caption)> {
self.caption_tracks.iter().find_map(|t| t.caption(id).map(|c| (t.id, c)))
}
pub fn tracks(&self, kind: TrackKind) -> &Vec<Track> {
match kind {
TrackKind::Video => &self.video_tracks,
TrackKind::Audio => &self.audio_tracks,
}
}
pub fn tracks_mut(&mut self, kind: TrackKind) -> &mut Vec<Track> {
match kind {
TrackKind::Video => &mut self.video_tracks,
TrackKind::Audio => &mut self.audio_tracks,
}
}
pub fn all_tracks(&self) -> impl Iterator<Item = &Track> {
self.video_tracks.iter().chain(self.audio_tracks.iter())
}
pub fn all_tracks_mut(&mut self) -> impl Iterator<Item = &mut Track> {
self.video_tracks.iter_mut().chain(self.audio_tracks.iter_mut())
}
pub fn track(&self, id: TrackId) -> Option<&Track> {
self.all_tracks().find(|t| t.id == id)
}
pub fn track_mut(&mut self, id: TrackId) -> Option<&mut Track> {
self.all_tracks_mut().find(|t| t.id == id)
}
/// Find a track item anywhere: (track id, item).
pub fn find_item(&self, id: ClipId) -> Option<(TrackId, &TrackItem)> {
self.all_tracks().find_map(|t| t.item(id).map(|i| (t.id, i)))
}
pub fn find_item_mut(&mut self, id: ClipId) -> Option<(TrackId, &mut TrackItem)> {
self.all_tracks_mut().find_map(|t| {
let tid = t.id;
t.item_mut(id).map(|i| (tid, i))
})
}
pub fn frame_rate(&self) -> FrameRate {
self.settings.frame_rate
}
/// Sorted, de-duplicated edit points (clip boundaries) on all tracks.
pub fn edit_points(&self) -> Vec<Tick> {
let mut v: Vec<Tick> = self.all_tracks().flat_map(|t| t.items.iter().flat_map(|i| [i.start, i.end()])).collect();
v.sort_unstable();
v.dedup();
v
}
pub fn check(&self) -> Result<(), String> {
for t in self.all_tracks() {
t.check()?;
}
for t in &self.caption_tracks {
t.check()?;
}
Ok(())
}
}
/// Project-level settings.
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
pub struct ProjectSettings {
pub renderer: String,
pub video_display: filmcraft_time::TimeDisplay,
pub audio_display_samples: bool,
pub default_still_duration: Tick,
pub default_transition_duration_frames: i64,
pub default_audio_transition_duration: Tick,
/// Project Settings ▸ Ingest Settings: what happens to media on import.
#[serde(default)]
pub ingest: IngestSettings,
}
/// Ingest on import (Project Settings ▸ Ingest Settings).
#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)]
#[serde(default, rename_all = "camelCase")]
pub struct IngestSettings {
pub enabled: bool,
pub action: IngestAction,
/// Destination folder for copies, transcodes and proxies (None = next to the media:
/// `<media dir>/Proxies` for proxies, `<media dir>/Ingest` for copies and transcodes).
pub destination: Option<String>,
/// Proxy / transcode preset id (the engine's `proxies::PRESETS`); empty = the default.
pub preset: String,
}
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub enum IngestAction {
/// Copy the files to the destination (verified), then use the copies.
Copy,
/// Transcode to the preset's format, then use the transcodes.
Transcode,
/// Create proxies (in the background) and attach them.
#[default]
CreateProxies,
/// Copy, then create proxies of the copies.
CopyAndCreateProxies,
}
impl Default for ProjectSettings {
fn default() -> Self {
Self {
renderer: "FilmCraft GPU Acceleration (wgpu)".into(),
video_display: filmcraft_time::TimeDisplay::Timecode,
audio_display_samples: true,
default_still_duration: Tick(5 * TICKS_PER_SECOND),
default_transition_duration_frames: 24,
default_audio_transition_duration: Tick(TICKS_PER_SECOND),
ingest: IngestSettings::default(),
}
}
}
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
pub struct Project {
pub name: String,
pub settings: ProjectSettings,
pub root: Bin,
pub items: BTreeMap<ItemId, ProjectItem>,
/// Monotonic id source for all id types.
pub next_id: u64,
/// The project's LUT library (Lumetri Input LUT / Creative Look "Browse…"). LUT files are
/// embedded, so projects render without the original files.
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub luts: Vec<ProjectLut>,
/// Transcripts of media items (Text panel ▸ Transcript), keyed by the media item; word times
/// are media time. Shared (`Arc`) so undo snapshots don't copy them.
#[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
pub transcripts: BTreeMap<ItemId, std::sync::Arc<Transcript>>,
}
/// A LUT imported into the project (`lut.import`). Lumetri refers to it as `lib:<id>`.
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
pub struct ProjectLut {
pub id: String,
pub name: String,
/// Where it was imported from (informational).
#[serde(default)]
pub source_path: Option<String>,
/// `cube` or `3dl`.
pub format: String,
/// The file's text.
pub text: std::sync::Arc<str>,
}
impl Default for Project {
fn default() -> Self {
Self::new("Untitled")
}
}
impl Project {
pub fn new(name: &str) -> Self {
Self {
name: name.into(),
settings: ProjectSettings::default(),
root: Bin { id: BinId(0), name: name.into(), children: Vec::new() },
items: BTreeMap::new(),
next_id: 1,
luts: Vec::new(),
transcripts: BTreeMap::new(),
}
}
pub fn alloc_id(&mut self) -> u64 {
let id = self.next_id;
self.next_id += 1;
id
}
/// Add an item to a bin (root when None / not found).
pub fn add_item(&mut self, name: &str, label: Label, kind: ItemKind, bin: Option<BinId>) -> ItemId {
let id = ItemId(self.alloc_id());
let created = id.0;
self.items.insert(id, ProjectItem { id, name: name.into(), label, kind, metadata: BTreeMap::new(), created, split: SplitMarks::default() });
if let Some(b) = bin.and_then(|b| self.root.find_bin_mut(b)) {
b.children.push(BinEntry::Item(id));
} else {
self.root.children.push(BinEntry::Item(id));
}
id
}
pub fn add_bin(&mut self, name: &str, parent: Option<BinId>) -> BinId {
let id = BinId(self.alloc_id());
let bin = Bin { id, name: name.into(), children: Vec::new() };
match parent.and_then(|p| self.root.find_bin_mut(p)) {
Some(p) => p.children.push(BinEntry::Bin(bin)),
None => self.root.children.push(BinEntry::Bin(bin)),
}
id
}
/// Move items into a bin (the root when `bin` is None). Returns how many moved; unknown items
/// are skipped. Fails (returns None) when the bin does not exist.
pub fn move_to_bin(&mut self, items: &[ItemId], bin: Option<BinId>) -> Option<usize> {
let target = bin.unwrap_or(self.root.id);
self.root.find_bin(target)?;
let mut moved = 0;
for &id in items {
if !self.items.contains_key(&id) || !self.root.remove_item(id) {
continue;
}
self.root.find_bin_mut(target).expect("checked above").children.push(BinEntry::Item(id));
moved += 1;
}
Some(moved)
}
pub fn item(&self, id: ItemId) -> Option<&ProjectItem> {
self.items.get(&id)
}
pub fn item_mut(&mut self, id: ItemId) -> Option<&mut ProjectItem> {
self.items.get_mut(&id)
}
pub fn sequence(&self, id: ItemId) -> Option<&Sequence> {
self.items.get(&id).and_then(ProjectItem::as_sequence)
}
pub fn sequence_mut(&mut self, id: ItemId) -> Option<&mut Sequence> {
self.items.get_mut(&id).and_then(ProjectItem::as_sequence_mut)
}
pub fn sequences(&self) -> impl Iterator<Item = &ProjectItem> {
self.items.values().filter(|i| matches!(i.kind, ItemKind::Sequence(_)))
}
/// Create an empty sequence with `v` video and `a` audio tracks.
pub fn new_sequence(&mut self, name: &str, settings: SequenceSettings, v: usize, a: usize, bin: Option<BinId>) -> ItemId {
let mut seq = Sequence {
settings,
video_tracks: Vec::new(),
audio_tracks: Vec::new(),
markers: Vec::new(),
mark_in: None,
mark_out: None,
work_area: None,
start_timecode: 0,
master_volume_db: 0.0,
master_effects: Vec::new(),
master_mixer: MixerStrip::default(),
submix_tracks: Vec::new(),
caption_tracks: Vec::new(),
multicam: None,
merged: None,
split: SplitMarks::default(),
};
for i in 0..v {
let id = TrackId(self.alloc_id());
seq.video_tracks.push(Track::new(id, TrackKind::Video, format!("Video {}", i + 1)));
}
for i in 0..a {
let id = TrackId(self.alloc_id());
seq.audio_tracks.push(Track::new(id, TrackKind::Audio, format!("Audio {}", i + 1)));
}
self.add_item(name, Label::Forest, ItemKind::Sequence(Box::new(seq)), bin)
}
/// Re-base an item's media time: whatever was at media time `t` is at `t - delta` afterwards
/// (a relink with Align Timecode, a consolidated file that starts later). Moves the source
/// in-points, frame holds and keyframes of every track item that shows the item or one of its
/// subclips, the subclip ranges, and the clip's own marks and markers.
pub fn shift_media_time(&mut self, item: ItemId, delta: Tick) {
if delta == Tick::ZERO {
return;
}
let mut users = vec![item];
for it in self.items.values_mut() {
if let ItemKind::Subclip { parent, range, .. } = &mut it.kind
&& *parent == item
{
range.start -= delta;
users.push(it.id);
}
}
for it in self.items.values_mut() {
if let ItemKind::Sequence(seq) = &mut it.kind {
for t in seq.all_tracks_mut() {
for ti in t.items.iter_mut().filter(|ti| users.contains(&ti.item)) {
ti.source_in -= delta;
if let Some(h) = &mut ti.frame_hold {
*h -= delta;
}
for e in &mut ti.effects {
for p in e.params.values_mut() {
for k in &mut p.keyframes {
k.time -= delta;
}
}
}
}
}
}
}
if let Some(m) = self.item_mut(item).and_then(|i| i.as_media_mut()) {
m.mark_in = m.mark_in.map(|t| t - delta);
m.mark_out = m.mark_out.map(|t| t - delta);
m.markers.iter_mut().for_each(|mk| mk.start -= delta);
}
}
/// Build a track item for `item` placed at `start` covering `source` range (media time).
pub fn make_track_item(&mut self, item: ItemId, kind: TrackKind, start: Tick, source: TimeRange, seq_rate: FrameRate) -> Option<TrackItem> {
let it = self.items.get(&item)?;
let name = it.name.clone();
let label = it.label;
let effects = match kind {
TrackKind::Video => effect::intrinsic_video(),
TrackKind::Audio => effect::intrinsic_audio(),
};
// a multi-camera source sequence edits in as a multi-camera clip showing its first angle
let multicam =
it.as_sequence().and_then(|q| q.multicam.as_ref()).map(|m| MulticamSel { enabled: true, angle: m.first_video_angle().unwrap_or(0) as u32 });
let source_channels = match (kind, &it.kind) {
(TrackKind::Audio, ItemKind::Media(m)) => m.interpret.audio_channels.as_ref().and_then(|a| a.clips.first().cloned()).unwrap_or_default(),
_ => Vec::new(),
};
let id = ClipId(self.alloc_id());
let dur = seq_rate.snap_nearest(source.duration).max(seq_rate.frame_duration());
Some(TrackItem {
id,
item,
name,
label,
start,
duration: dur,
source_in: source.start,
speed: 1.0,
reverse: false,
enabled: true,
link: None,
group: None,
effects,
markers: Vec::new(),
gain_db: 0.0,
frame_hold: None,
scale_to_frame: false,
essential: None,
multicam,
time_interpolation: TimeInterpolation::default(),
hold_filters: false,
field_options: None,
source_channels,
})
}
/// The bare model as JSON. Project *files* add a schema-versioned envelope; read and write them
/// with `filmcraft-format`, not with these.
pub fn to_json(&self) -> String {
serde_json::to_string_pretty(self).unwrap_or_default()
}
pub fn from_json(s: &str) -> Result<Project, String> {
serde_json::from_str(s).map_err(|e| e.to_string())
}
}
/// Resolve NaN "auto" point defaults (frame centre / source centre) in an effect instance.
pub fn resolve_auto_points(e: &mut EffectInstance, frame: (u32, u32), source: (u32, u32)) {
for (k, p) in e.params.iter_mut() {
if let ParamValue::Vec2(v) = &mut p.value {
let (w, h) = if k == "anchor" { source } else { frame };
if v.x.is_nan() {
v.x = w as f64 / 2.0;
}
if v.y.is_nan() {
v.y = if k == "end" { h as f64 } else { h as f64 / 2.0 };
}
}
}
let _ = Vec2::ZERO;
}
#[cfg(test)]
mod tests {
use super::*;
use filmcraft_media::{DemoScene, MediaSource};
fn demo_project() -> (Project, ItemId, ItemId) {
let mut p = Project::new("Test");
let src = filmcraft_media::generators::GeneratorSource::demo(DemoScene::OceanSunset);
let info = src.info().clone();
let clip = p.add_item(
"Ocean_Sunset.mp4",
Label::Iris,
ItemKind::Media(MediaClip {
media: MediaRef::Generator(Generator::Demo(DemoScene::OceanSunset)),
info,
interpret: Default::default(),
mark_in: None,
mark_out: None,
markers: vec![],
offline: false,
proxy: None,
identity: None,
}),
None,
);
let seq = p.new_sequence("Sequence 01", SequenceSettings::default(), 3, 3, None);
(p, clip, seq)
}
#[test]
fn json_roundtrip() {
let (mut p, clip, seq) = demo_project();
let rate = p.sequence(seq).unwrap().settings.frame_rate;
let mut ti = p.make_track_item(clip, TrackKind::Video, Tick::ZERO, TimeRange::new(Tick::ZERO, Tick(5 * TICKS_PER_SECOND)), rate).unwrap();
for e in &mut ti.effects {
resolve_auto_points(e, (1920, 1080), (1920, 1080));
}
p.sequence_mut(seq).unwrap().video_tracks[0].items.push(ti);
let s = p.to_json();
let q = Project::from_json(&s).unwrap();
assert_eq!(p, q);
assert!(q.sequence(seq).unwrap().check().is_ok());
}
#[test]
fn item_at_and_source_time() {
let (mut p, clip, seq) = demo_project();
let rate = FrameRate::FPS_24;
let mut a = p.make_track_item(clip, TrackKind::Video, Tick(0), TimeRange::new(Tick(1000), rate.tick_of(48)), rate).unwrap();
a.speed = 2.0;
let t = &mut p.sequence_mut(seq).unwrap().video_tracks[0];
t.items.push(a.clone());
assert_eq!(t.item_at(rate.tick_of(10)).unwrap().id, a.id);
assert!(t.item_at(rate.tick_of(48)).is_none());
assert_eq!(a.source_time_at(rate.tick_of(10)), Tick(1000) + rate.tick_of(20));
}
#[test]
fn projects_without_caption_tracks_load() {
let (p, _, seq) = demo_project();
let mut v: serde_json::Value = serde_json::from_str(&p.to_json()).unwrap();
// a v1 file has no `caption_tracks` key
let items = v["items"].as_object_mut().unwrap();
for it in items.values_mut() {
if let Some(s) = it["kind"].get_mut("Sequence") {
s.as_object_mut().unwrap().remove("caption_tracks");
}
}
let q = Project::from_json(&v.to_string()).unwrap();
assert!(q.sequence(seq).unwrap().caption_tracks.is_empty());
}
#[test]
fn caption_tracks_roundtrip() {
let (mut p, _, seq) = demo_project();
let id = TrackId(p.alloc_id());
let mut ct = CaptionTrack::new(id, "Subtitle".into(), CaptionFormat::Cea608);
ct.captions.push(Caption {
id: ClipId(p.alloc_id()),
start: Tick(10),
duration: Tick(TICKS_PER_SECOND),
text: "Hello\n<i>world</i>".into(),
speaker: Some("Ann".into()),
cue_id: Some("1".into()),
settings: "line:90%".into(),
});
p.sequence_mut(seq).unwrap().caption_tracks.push(ct);
let q = Project::from_json(&p.to_json()).unwrap();
assert_eq!(p, q);
assert_eq!(q.sequence(seq).unwrap().duration(), Tick(10 + TICKS_PER_SECOND));
}
#[test]
fn bins() {
let mut p = Project::new("x");
let b = p.add_bin("Footage", None);
let i = p.add_item("a", Label::Iris, ItemKind::AdjustmentLayer { width: 10, height: 10, rate: FrameRate::FPS_24, duration: Tick(1) }, Some(b));
assert_eq!(p.root.parent_of(i), Some(b));
assert!(p.root.remove_item(i));
assert_eq!(p.root.parent_of(i), None);
}
}