filmcraft/crates/project/src/lib.rs

1973 lines
80 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.
#![cfg_attr(not(test), deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable))]
pub mod caption;
pub mod effect;
pub mod essential;
pub mod find;
pub mod graphic;
pub mod graphic_design;
pub mod gtemplate;
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 find::{FindOp, FindQuery, FindRow, SearchBin};
pub use graphic_design::{CharStyle, GraphicMeta, LayerExtra, Pin, PinTarget, Roll, RollMode, SourceGraphic, StyleRun};
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 })
}
/// Take a sub-bin, and everything in it, out of the tree. The bin itself is never removed from itself.
pub fn remove_bin(&mut self, id: BinId) -> Option<Bin> {
if let Some(at) = self.children.iter().position(|c| matches!(c, BinEntry::Bin(b) if b.id == id))
&& let BinEntry::Bin(b) = self.children.remove(at)
{
return Some(b);
}
self.children.iter_mut().find_map(|c| if let BinEntry::Bin(b) = c { b.remove_bin(id) } else { None })
}
/// 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,
/// Markers ▸ Add Flash Cue Marker… (a cue point for interactive / Flash-style playback; kept
/// for interchange). Schema v11.
FlashCue,
}
#[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.
fn is_zero_stream(stream: &usize) -> bool {
*stream == 0
}
#[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>,
/// Audio stream within the media container (0 = the primary stream).
#[serde(default, skip_serializing_if = "is_zero_stream")]
pub audio_stream: usize,
/// Graphic clips: roll / crawl, responsive time and the template the graphic came from
/// ([`GraphicMeta`]). Schema v12.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub graphic: Option<Box<GraphicMeta>>,
}
/// 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> {
// Validate before calling end(), including on a single-item track.
self.check_bounds()?;
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(())
}
/// Only the value bounds that keep timeline arithmetic from overflowing (no structural rules
/// such as overlap or positive duration); what a loaded project must satisfy.
pub fn check_bounds(&self) -> Result<(), String> {
for item in &self.items {
if !bounded_time_range(item.start, item.duration) {
return Err(format!("{}: item {:?} is outside supported time bounds", self.name, item.id));
}
}
for transition in &self.transitions {
if !bounded_time_range(transition.start, transition.duration) {
return Err(format!("{}: transition {:?} is outside supported time bounds", self.name, transition.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,
}
}
}
/// Largest frame side, in pixels.
pub const MAX_FRAME_SIDE: u32 = 32_768;
/// Largest frame area, in pixels (16384 x 16384: 16K and 16384 x 8192 panoramas fit).
pub const MAX_FRAME_PIXELS: u64 = 268_435_456;
/// Bound working-frame allocations while retaining 16K and wide panoramic frames.
pub fn validate_frame_size(width: u32, height: u32) -> Result<(), String> {
if width == 0 || height == 0 || width > MAX_FRAME_SIDE || height > MAX_FRAME_SIDE || u64::from(width) * u64::from(height) > MAX_FRAME_PIXELS {
return Err(format!("frame size must be positive, at most {MAX_FRAME_SIDE} pixels per side and {MAX_FRAME_PIXELS} pixels total"));
}
Ok(())
}
impl SequenceSettings {
pub fn validate(&self) -> Result<(), String> {
validate_frame_size(self.width, self.height)?;
if self.frame_rate.num <= 0
|| self.frame_rate.den <= 0
|| self.frame_rate.as_f64() > 1000.0
|| self.frame_rate.num > i64::from(u32::MAX)
|| self.frame_rate.den > i64::from(u32::MAX)
|| (i128::from(TICKS_PER_SECOND) * i128::from(self.frame_rate.den) / i128::from(self.frame_rate.num.max(1))) > i128::from(Tick::MAX.0)
{
return Err("frame rate must be positive, at most 1000 fps, with unsigned 32-bit rational components".into());
}
if self.sample_rate == 0 || self.sample_rate > 384_000 {
return Err("sample rate must be between 1 and 384000 Hz".into());
}
if self.par.0 == 0 || self.par.1 == 0 {
return Err("pixel aspect ratio must be positive".into());
}
Ok(())
}
}
#[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> {
self.check_bounds()?;
for t in self.all_tracks() {
t.check()?;
}
for t in &self.caption_tracks {
t.check()?;
}
Ok(())
}
/// The hostile-value bounds alone (settings, marks and every time range), without the
/// structural invariants of [`Sequence::check`]: a project that breaks only those (e.g. an
/// overlap written by an older build) still opens, but nothing in it can overflow.
pub fn check_bounds(&self) -> Result<(), String> {
self.settings.validate()?;
if self.mark_in.into_iter().chain(self.mark_out).any(|t| t < Tick::MIN || t > Tick::MAX)
|| self.work_area.is_some_and(|r| !bounded_time_range(r.start, r.duration))
{
return Err("sequence marks or work area are outside supported time bounds".into());
}
for t in self.all_tracks() {
t.check_bounds()?;
}
for t in &self.caption_tracks {
t.check_bounds()?;
}
Ok(())
}
}
/// Bound persisted timeline arithmetic before any start + duration operation: start, duration and
/// end all stay within `Tick::MIN..=Tick::MAX`, leaving headroom for later edit math. Negative
/// positions remain supported; the sign of the duration is a structural rule (track items and
/// captions require a positive one in their own invariant checks), not a bound.
pub(crate) fn bounded_time_range(start: Tick, duration: Tick) -> bool {
let within = |t: i64| (Tick::MIN.0..=Tick::MAX.0).contains(&t);
within(start.0) && within(duration.0) && start.0.checked_add(duration.0).is_some_and(within)
}
/// 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,
/// Kept for file compatibility; the editor uses Settings ▸ Timeline (user preferences) for
/// still and transition default durations, as Premiere does.
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,
/// Project Settings ▸ General ▸ Action and Title Safe Areas: (horizontal, vertical) percent.
#[serde(default = "title_safe_default")]
pub title_safe: (f64, f64),
#[serde(default = "action_safe_default")]
pub action_safe: (f64, f64),
/// Project Settings ▸ General ▸ Capture Format (`DV` or `HDV`; informational, FilmCraft has no
/// tape capture). Schema v11.
#[serde(default = "capture_format_default")]
pub capture_format: String,
/// Project Settings ▸ Scratch Disks. Schema v11.
#[serde(default)]
pub scratch: ScratchDisks,
}
fn title_safe_default() -> (f64, f64) {
(20.0, 20.0)
}
fn action_safe_default() -> (f64, f64) {
(10.0, 10.0)
}
fn capture_format_default() -> String {
"DV".into()
}
/// Project Settings ▸ Scratch Disks: where generated files go. `None` = Same as Project (next to
/// the project file; the data directory for an unsaved project).
#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
#[serde(default, rename_all = "camelCase")]
pub struct ScratchDisks {
/// Captured and Generated media: proxies (when no ingest destination is set), Extract Audio.
pub captured: Option<String>,
/// Video and audio render previews (`<dir>/<project name>`).
pub video_previews: Option<String>,
pub audio_previews: Option<String>,
/// Project Auto Save versions.
pub auto_save: Option<String>,
}
/// 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(),
title_safe: title_safe_default(),
action_safe: action_safe_default(),
capture_format: capture_format_default(),
scratch: ScratchDisks::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>>,
/// Search bins (File ▸ New ▸ Search Bin): saved Find queries listed in the Project panel.
/// Schema v11.
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub search_bins: Vec<SearchBin>,
/// Source graphics (Graphics and Titles ▸ Upgrade to Source Graphic): shared layers by
/// project item. Schema v12.
#[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
pub source_graphics: BTreeMap<ItemId, SourceGraphic>,
}
/// How a sequence is shown in the Timeline panel: its zoom, scroll position and track heights.
/// Each open sequence keeps its own (as Premiere's sequence tabs do).
#[derive(Clone, Copy, Debug, PartialEq, Serialize, Deserialize)]
pub struct SequenceView {
/// Zoom: points per second.
pub pps: f64,
/// Time at the left edge, in seconds.
pub scroll: f64,
/// Vertical scroll of the video and audio halves, in points.
#[serde(default)]
pub v_scroll: f32,
#[serde(default)]
pub a_scroll: f32,
pub video_track_h: f32,
pub audio_track_h: f32,
}
impl SequenceView {
/// The view with every number brought into a usable range, or `None` when a number is not
/// finite. Views are read from project files, so nothing in them is trusted.
pub fn checked(self) -> Option<SequenceView> {
let all = [self.pps, self.scroll, self.v_scroll as f64, self.a_scroll as f64, self.video_track_h as f64, self.audio_track_h as f64];
if all.iter().any(|x| !x.is_finite()) {
return None;
}
Some(SequenceView {
pps: self.pps.clamp(1e-3, 1e5),
scroll: self.scroll.clamp(0.0, 1e7),
v_scroll: self.v_scroll.clamp(0.0, 1e6),
a_scroll: self.a_scroll.clamp(0.0, 1e6),
video_track_h: self.video_track_h.clamp(8.0, 600.0),
audio_track_h: self.audio_track_h.clamp(8.0, 600.0),
})
}
}
/// What was open when a project was saved: the Timeline's sequence tabs in order, the active one,
/// how each sequence was shown and where its playhead was. Stored beside the project in its file,
/// not in it: it is not part of the edit and never an undo step.
#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)]
pub struct ProjectView {
#[serde(default)]
pub open_sequences: Vec<ItemId>,
#[serde(default)]
pub active_sequence: Option<ItemId>,
#[serde(default)]
pub sequences: BTreeMap<ItemId, SequenceView>,
/// Each sequence's playhead, so a sequence reopens where it was left. Files
/// written before it existed have none, and every playhead starts at zero as it did then.
#[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
pub playheads: BTreeMap<ItemId, Tick>,
}
impl ProjectView {
/// The latest playhead a project file may ask for: ten million seconds (about 115 days), the
/// same bound as a sequence view's scroll position. Playheads are read from project files, so
/// nothing in them is trusted.
pub const MAX_PLAYHEAD: Tick = Tick(10_000_000 * TICKS_PER_SECOND);
}
/// 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(),
search_bins: Vec::new(),
source_graphics: 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;
}
if let Some(b) = self.root.find_bin_mut(target) {
b.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(_)))
}
/// The part of a nested-sequence clip's timeline range that lies past the end of the nested
/// sequence's contents: it shows nothing and is silent, and the Timeline hatches it. A nest
/// keeps its length when its sequence gets shorter, so this is what is left to trim off.
/// `None` for other clips and for nests that end within their contents.
pub fn nest_overhang(&self, clip: &TrackItem) -> Option<TimeRange> {
let contents = self.sequence(clip.item)?.duration();
if let Some(held) = clip.frame_hold {
return (held >= contents).then(|| clip.range());
}
let past = clip.source_out() - contents;
if past <= Tick::ZERO {
return None;
}
let speed = clip.speed.abs();
if clip.source_in >= contents || !speed.is_finite() || speed < 1e-9 {
return Some(clip.range());
}
// source time past the contents, as timeline time
let len = Tick(((past.0 as f64 / speed).round() as i64).clamp(0, clip.duration.0.max(0)));
if len <= Tick::ZERO {
return None;
}
// a reversed clip plays its source's end first
Some(if clip.reverse { TimeRange::new(clip.start, len) } else { TimeRange::new(clip.end() - len, len) })
}
/// A sequence that contains itself, directly or through the sequences nested in it (the lowest
/// id of those on a cycle), or `None` when nesting is sound. Such a sequence has no frame to
/// show, so edits that would make one are refused; only a damaged project file holds one.
pub fn nest_cycle(&self) -> Option<ItemId> {
// each sequence's nested sequences
let mut nests: BTreeMap<ItemId, Vec<ItemId>> = BTreeMap::new();
for it in self.items.values() {
let ItemKind::Sequence(q) = &it.kind else { continue };
let mut inner: Vec<ItemId> = q.all_tracks().flat_map(|t| t.items.iter().map(|i| i.item)).filter(|i| self.sequence(*i).is_some()).collect();
inner.sort_unstable();
inner.dedup();
if !inner.is_empty() {
nests.insert(it.id, inner);
}
}
// drop sequences whose nests all lead out of the set, until none can be dropped: what is
// left lies on a cycle or leads into one
loop {
let leaves: Vec<ItemId> = nests.iter().filter(|(_, inner)| inner.iter().all(|i| !nests.contains_key(i))).map(|(id, _)| *id).collect();
if leaves.is_empty() {
break;
}
for id in leaves {
nests.remove(&id);
}
}
// of those, the ones that can reach themselves
nests.keys().copied().find(|start| {
let mut seen: Vec<ItemId> = Vec::new();
let mut todo: Vec<ItemId> = nests.get(start).cloned().unwrap_or_default();
while let Some(id) = todo.pop() {
if id == *start {
return true;
}
if !seen.contains(&id) {
seen.push(id);
todo.extend(nests.get(&id).into_iter().flatten().copied());
}
}
false
})
}
/// 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 mut effects = match kind {
TrackKind::Video => effect::intrinsic_video(),
TrackKind::Audio => effect::intrinsic_audio(),
};
// a source graphic edits in with its shared layers and settings
let source_graphic = self.source_graphics.get(&item).filter(|_| kind == TrackKind::Video).cloned();
if let Some(sg) = &source_graphic {
effects.extend(sg.layers.iter().cloned());
}
// 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,
audio_stream: 0,
graphic: source_graphic.and_then(|sg| sg.meta.map(Box::new)),
})
}
/// 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)) {
resolve_auto_points_sized(e, frame, Some(source));
}
/// [`resolve_auto_points`] for a source whose size may not be known yet (`None`): the points
/// measured in the source (`anchor`) then stay "auto".
fn resolve_auto_points_sized(e: &mut EffectInstance, frame: (u32, u32), source: Option<(u32, u32)>) {
for (k, p) in e.params.iter_mut() {
if let ParamValue::Vec2(v) = &mut p.value {
let Some((w, h)) = (if k == "anchor" { source } else { Some(frame) }) else { continue };
if let Some((fx, fy)) = effect::auto_point(&e.effect, k) {
if v.x.is_nan() {
v.x = w as f64 * fx;
}
if v.y.is_nan() {
v.y = h as f64 * fy;
}
}
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;
}
impl Project {
/// Resolve media through at most sixteen items, preserving the outermost subclip range.
/// Missing parents, cycles and longer chains are unavailable media.
pub fn resolve_media(&self, item: ItemId) -> Option<(ItemId, &MediaClip, Option<TimeRange>)> {
let mut id = item;
let mut range = None;
for _ in 0..16 {
match &self.item(id)?.kind {
ItemKind::Media(media) => return Some((id, media, range)),
ItemKind::Subclip { parent, range: span, .. } => {
range.get_or_insert(*span);
id = *parent;
}
_ => return None,
}
}
None
}
/// Size in pixels of what an item shows: a media clip's picture, a sequence's frame, an
/// adjustment layer or graphic; a subclip has the size of its parent. `None` without
/// picture. This is the size the renderer centres a clip's "auto" anchor in.
pub fn source_size(&self, item: ItemId) -> Option<(u32, u32)> {
let mut id = item;
// subclips of subclips: bounded, a damaged project could point a subclip at itself
for _ in 0..16 {
return match &self.item(id)?.kind {
ItemKind::Media(m) => m.info.video.as_ref().map(|v| (v.width, v.height)),
ItemKind::Sequence(q) => Some((q.settings.width, q.settings.height)),
ItemKind::AdjustmentLayer { width, height, .. } | ItemKind::Graphic { width, height, .. } => Some((*width, *height)),
ItemKind::Subclip { parent, .. } => {
id = *parent;
continue;
}
};
}
None
}
/// Resolve the "auto" (NaN) points of the clips in the sequences `sequences` selects, for
/// clips that did not go through a placing command (an interchange import builds them with
/// the effect defaults). Points measured in the frame use the sequence's size. `anchor` is
/// measured in the clip's source ([`Project::source_size`], a subclip through its parent) and
/// is resolved only when `size_is_final` says so for that source item (the media item for a
/// subclip); otherwise it stays "auto", which the renderer centres in whatever size the media
/// turns out to have. Points that are not NaN are never touched, so this can run again when
/// more sizes are known.
pub fn resolve_placed_auto_points(&mut self, sequences: impl Fn(ItemId) -> bool, size_is_final: impl Fn(&ProjectItem) -> bool) {
let seq_ids: Vec<ItemId> = self.items.values().filter(|i| matches!(i.kind, ItemKind::Sequence(_)) && sequences(i.id)).map(|i| i.id).collect();
for seq_id in seq_ids {
let Some(seq) = self.sequence(seq_id) else { continue };
// the source size of each clip that has an unresolved point, looked up before the edit
let sources: BTreeMap<ClipId, Option<(u32, u32)>> = seq
.all_tracks()
.flat_map(|t| t.items.iter())
.filter(|c| c.effects.iter().any(has_auto_point))
.map(|c| (c.id, self.final_source_size(c.item, &size_is_final)))
.collect();
if sources.is_empty() {
continue;
}
let Some(seq) = self.sequence_mut(seq_id) else { continue };
let frame = (seq.settings.width, seq.settings.height);
for clip in seq.all_tracks_mut().flat_map(|t| t.items.iter_mut()) {
let Some(source) = sources.get(&clip.id) else { continue };
for e in &mut clip.effects {
resolve_auto_points_sized(e, frame, *source);
}
}
}
}
/// [`Project::source_size`] of `item` when the item that carries the size (the media item of
/// a subclip) passes `size_is_final`.
fn final_source_size(&self, item: ItemId, size_is_final: &impl Fn(&ProjectItem) -> bool) -> Option<(u32, u32)> {
let mut id = item;
for _ in 0..16 {
let it = self.item(id)?;
match &it.kind {
ItemKind::Subclip { parent, .. } => id = *parent,
_ => return if size_is_final(it) { self.source_size(id) } else { None },
}
}
None
}
}
fn has_auto_point(e: &EffectInstance) -> bool {
e.params.values().any(|p| matches!(&p.value, ParamValue::Vec2(v) if v.x.is_nan() || v.y.is_nan()))
}
#[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 hostile_frame_rates_are_rejected_before_frame_arithmetic() {
for frame_rate in [FrameRate { num: i64::MAX, den: i64::MAX }, FrameRate { num: 1, den: i64::from(u32::MAX) }] {
let settings = SequenceSettings { frame_rate, ..Default::default() };
assert!(settings.validate().is_err());
}
for (width, height) in [(7680, 4320), (15360, 8640), (16384, 8192), (16384, 16384), (32768, 8192)] {
assert!(SequenceSettings { width, height, ..Default::default() }.validate().is_ok(), "{width}x{height}");
}
assert!(validate_frame_size(32768, 16384).is_err());
assert!(validate_frame_size(65536, 1).is_err());
}
#[test]
fn corrupt_timeline_times_are_rejected_before_end_arithmetic() {
let (mut project, media, seq) = demo_project();
let rate = project.sequence(seq).unwrap().settings.frame_rate;
let range = TimeRange::new(Tick::ZERO, rate.tick_of(24));
let first = project.make_track_item(media, TrackKind::Video, Tick::ZERO, range, rate).unwrap();
let second = project.make_track_item(media, TrackKind::Video, rate.tick_of(24), range, rate).unwrap();
project.sequence_mut(seq).unwrap().video_tracks[0].items = vec![first, second];
for start in [Tick(i64::MAX), Tick(i64::MIN), Tick::MAX] {
project.sequence_mut(seq).unwrap().video_tracks[0].items[0].start = start;
let result = std::panic::catch_unwind(|| project.sequence(seq).unwrap().check());
assert!(result.is_ok());
assert!(result.unwrap().is_err());
}
project.sequence_mut(seq).unwrap().video_tracks[0].items[0].start = Tick::ZERO;
for mark in [Tick(i64::MAX), Tick(i64::MIN)] {
project.sequence_mut(seq).unwrap().mark_out = Some(mark);
assert!(project.sequence(seq).unwrap().check().is_err());
}
}
#[test]
fn point_params_keep_auto_through_json_and_no_other_point_reads_null() {
// serde_json writes NaN as `null`; a point parameter reads it back as NaN ("auto")
let json = serde_json::to_string(&ParamValue::Vec2(Vec2::new(f64::NAN, 540.0))).unwrap();
assert_eq!(json, r#"{"Vec2":{"x":null,"y":540.0}}"#);
let v = serde_json::from_str::<ParamValue>(&json).unwrap().as_vec2().unwrap();
assert!(v.x.is_nan() && v.y == 540.0);
let both = serde_json::from_str::<ParamValue>(r#"{"Vec2":{"x":null,"y":null}}"#).unwrap().as_vec2().unwrap();
assert!(both.x.is_nan() && both.y.is_nan());
assert_eq!(serde_json::from_str::<ParamValue>(r#"{"Vec2":{"x":1.5,"y":-2}}"#).unwrap(), ParamValue::Vec2(Vec2::new(1.5, -2.0)));
// anything else is still an error, and so is a missing coordinate
for bad in [r#"{"Vec2":{"x":"a","y":0}}"#, r#"{"Vec2":{"x":0}}"#, r#"{"Vec2":null}"#, r#"{"Float":null}"#] {
assert!(serde_json::from_str::<ParamValue>(bad).is_err(), "{bad}");
}
// only point parameters: a `null` coordinate in a mask vertex (or any other point) is damage
assert!(serde_json::from_str::<Vec2>(r#"{"x":null,"y":0}"#).is_err());
let mut path = serde_json::to_value(MaskPath::ellipse(Vec2::new(100.0, 100.0), Vec2::new(50.0, 40.0))).unwrap();
assert!(serde_json::from_value::<MaskPath>(path.clone()).is_ok());
path["vertices"][0]["p"]["x"] = serde_json::Value::Null;
assert!(serde_json::from_value::<MaskPath>(path.clone()).is_err());
assert!(serde_json::from_value::<ParamValue>(serde_json::json!({ "Path": path })).is_err());
}
#[test]
fn media_resolution_bounds_depth_and_preserves_the_outer_range() {
let (mut p, media, _) = demo_project();
assert_eq!(p.resolve_media(media).map(|(id, _, range)| (id, range)), Some((media, None)));
let mut parent = media;
let mut last_range = TimeRange::default();
for n in 0..15 {
last_range = TimeRange::new(Tick(n * 1000), Tick(100));
parent = p.add_item("Sub", Label::Iris, ItemKind::Subclip { parent, range: last_range, restrict_trims: false }, None);
}
assert_eq!(p.resolve_media(parent).map(|(id, _, range)| (id, range)), Some((media, Some(last_range))));
let too_deep = p.add_item("Too deep", Label::Iris, ItemKind::Subclip { parent, range: last_range, restrict_trims: false }, None);
assert!(p.resolve_media(too_deep).is_none());
assert!(p.resolve_media(ItemId(u64::MAX)).is_none());
}
#[test]
fn placed_auto_points_resolve_once_the_source_size_is_final() {
let (mut p, clip, seq) = demo_project();
let src = p.item(clip).and_then(|i| i.as_media()).and_then(|m| m.info.video.clone()).unwrap();
let rate = p.sequence(seq).unwrap().settings.frame_rate;
let second = TimeRange::new(Tick::ZERO, Tick(TICKS_PER_SECOND));
let sub = p.add_item("Sub", Label::Iris, ItemKind::Subclip { parent: clip, range: second, restrict_trims: true }, None);
let sub_of_sub = p.add_item("Sub 2", Label::Iris, ItemKind::Subclip { parent: sub, range: second, restrict_trims: true }, None);
assert_eq!(p.source_size(clip), Some((src.width, src.height)));
assert_eq!((p.source_size(sub), p.source_size(sub_of_sub)), (p.source_size(clip), p.source_size(clip)), "a subclip has its parent's size");
assert_eq!(p.source_size(ItemId(987_654_321)), None);
// clips whose Motion position / anchor were never resolved (as an interchange import builds them)
let mut ids = Vec::new();
for (n, item) in [clip, sub, sub_of_sub].into_iter().enumerate() {
let ti = p.make_track_item(item, TrackKind::Video, Tick(n as i64 * TICKS_PER_SECOND), second, rate).unwrap();
assert!(ti.effect("motion").unwrap().vec2_at("position", Tick::ZERO).x.is_nan());
ids.push(ti.id);
p.sequence_mut(seq).unwrap().video_tracks[0].items.push(ti);
}
let st = p.sequence(seq).unwrap().settings.clone();
let point = |p: &Project, id: ClipId, k: &str| p.sequence(seq).unwrap().find_item(id).unwrap().1.effect("motion").unwrap().vec2_at(k, Tick::ZERO);
let (frame_centre, source_centre) =
(Vec2::new(st.width as f64 / 2.0, st.height as f64 / 2.0), Vec2::new(src.width as f64 / 2.0, src.height as f64 / 2.0));
// sequences that are not selected are left alone
let mut untouched = p.clone();
untouched.resolve_placed_auto_points(|_| false, |_| true);
assert!(point(&untouched, ids[0], "position").x.is_nan());
// the source size is not final yet: the frame's points resolve, the anchor stays auto
p.resolve_placed_auto_points(|_| true, |_| false);
for id in &ids {
assert_eq!(point(&p, *id, "position"), frame_centre);
assert!(point(&p, *id, "anchor").x.is_nan() && point(&p, *id, "anchor").y.is_nan());
}
// ...and survives a save: NaN is written as `null` and read back as NaN
let mut q = Project::from_json(&p.to_json()).unwrap();
assert!(point(&q, ids[0], "anchor").x.is_nan());
assert_eq!(q.to_json(), p.to_json());
// final for the media item only: its clip and the clips of its subclips get the source centre
q.resolve_placed_auto_points(|_| true, |i| i.id == clip);
for id in &ids {
assert_eq!((point(&q, *id, "position"), point(&q, *id, "anchor")), (frame_centre, source_centre));
}
assert!(!q.to_json().contains("null}}"), "no NaN point is left to write");
// running it again changes nothing
let once = q.clone();
q.resolve_placed_auto_points(|_| true, |_| true);
assert_eq!(q, once);
}
#[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);
}
#[test]
fn nest_cycle_finds_sequences_that_contain_themselves() {
let (mut p, clip, a) = demo_project();
let rate = p.sequence(a).unwrap().settings.frame_rate;
let b = p.new_sequence("b", SequenceSettings::default(), 1, 1, None);
let c = p.new_sequence("c", SequenceSettings::default(), 1, 1, None);
let put = |p: &mut Project, outer: ItemId, inner: ItemId| {
let it = p.make_track_item(inner, TrackKind::Video, Tick::ZERO, TimeRange::new(Tick::ZERO, rate.tick_of(10)), rate).unwrap();
let id = it.id;
p.sequence_mut(outer).unwrap().video_tracks[0].items.push(it);
id
};
// media and a chain of nests (a in b in c, a twice in c) are sound
put(&mut p, a, clip);
put(&mut p, b, a);
put(&mut p, c, b);
put(&mut p, c, a);
assert_eq!(p.nest_cycle(), None);
// c inside a closes the loop a → c → b → a
let closing = put(&mut p, a, c);
assert_eq!(p.nest_cycle(), Some(a));
p.sequence_mut(a).unwrap().video_tracks[0].items.retain(|i| i.id != closing);
assert_eq!(p.nest_cycle(), None);
// a sequence directly inside itself; one that only leads into the loop is not reported
put(&mut p, c, c);
let d = p.new_sequence("d", SequenceSettings::default(), 1, 1, None);
put(&mut p, d, c);
assert_eq!(p.nest_cycle(), Some(c));
}
#[test]
fn nest_overhang_is_the_part_past_the_contents() {
let (mut p, clip, a) = demo_project();
let rate = p.sequence(a).unwrap().settings.frame_rate;
let f = |n: i64| rate.tick_of(n);
// a holds 100 frames of media; b nests a for 150 frames starting at frame 10
let media = p.make_track_item(clip, TrackKind::Video, Tick::ZERO, TimeRange::new(Tick::ZERO, f(100)), rate).unwrap();
assert_eq!(p.nest_overhang(&media), None, "not a nest");
p.sequence_mut(a).unwrap().video_tracks[0].items.push(media);
let mut nest = p.make_track_item(a, TrackKind::Video, f(10), TimeRange::new(Tick::ZERO, f(150)), rate).unwrap();
assert_eq!(p.nest_overhang(&nest), Some(TimeRange::new(f(110), f(50))));
// within the contents, or exactly to their end: none
nest.duration = f(100);
assert_eq!(p.nest_overhang(&nest), None);
// trimmed in: 60 frames of contents are left
nest.source_in = f(40);
assert_eq!(p.nest_overhang(&nest), Some(TimeRange::new(f(70), f(40))));
// starting past the end: all of it
nest.source_in = f(100);
assert_eq!(p.nest_overhang(&nest), Some(nest.range()));
// double speed uses the contents up twice as fast
nest.source_in = Tick::ZERO;
nest.speed = 2.0;
assert_eq!(p.nest_overhang(&nest), Some(TimeRange::new(f(60), f(50))));
// reversed, the empty part comes first
nest.reverse = true;
assert_eq!(p.nest_overhang(&nest), Some(TimeRange::new(f(10), f(50))));
// a frame hold shows one frame throughout
nest.reverse = false;
nest.speed = 1.0;
nest.frame_hold = Some(f(20));
assert_eq!(p.nest_overhang(&nest), None);
nest.frame_hold = Some(f(100));
assert_eq!(p.nest_overhang(&nest), Some(nest.range()));
}
}