mirror of
https://github.com/storytold/craft-libs.git
synced 2026-11-14 06:22:24 +00:00
craft-timeline: a neutral timeline model and the import builder (#977)
model: a Project of bins and items (media, sequences, subclips, and items no interchange format carries), sequences of tracks with clips, transitions, markers and effects with keyframes. Cut down from FilmCraft's project model to what Final Cut Pro 7 XML, FCPXML and OpenTimelineIO describe; EffectCraft already uses that shape for its interchange. Three things stay the application's: effects (looked up through a Catalog the caller supplies), generators beyond the common ones (Generator::Other with an opaque payload), and per-object extension JSON (ext) that a format can carry unread. fx: the effect and parameter ids the formats name themselves, and Standard, a catalog of just those. build: FilmCraft's import Builder and shared helpers (filmcraft-interchange common.rs at 4bec22bd) on the model, with the effect registry replaced by the catalog, and the bounds every import ends with (Imported::bound, from lib.rs).
This commit is contained in:
1 parent
603d43da85
commit
7abf3186da
5 files changed
+1579
-2
No files matched your search
@@ -9,12 +9,16 @@ text formats that are documents of their own.
|
||||
| `rate` | Frame-rate conventions of documents: `timebase` + `ntsc`, floating rates snapped to exact ones, the highest rate an importer accepts (`MAX_RATE`). |
|
||||
| `report` | `Report`: what an import or export could not represent exactly, identical messages merged with a count. |
|
||||
| `path` | Media references: `file://` URLs (`localhost`, drive letters, UNC hosts, percent-encoding) to and from paths, and relative references joined to a document's folder. Lexical only. |
|
||||
| `model` | A neutral timeline: a `Project` of bins and items (media with what a document says about it, sequences, subclips), sequences of video and audio `Track`s holding `TrackItem`s (speed, reverse, freeze frame, links, effects with keyframes, markers) and `Transition`s. `Catalog` is the application's effects, supplied by the caller. Every object has an `ext` slot for application JSON a format can carry unread. |
|
||||
| `fx` | The effect and parameter ids the formats name themselves (motion, opacity, time remap, volume, pan, the common dissolves) and `Standard`, a catalog of just those. |
|
||||
| `build` | Building a `Project` while reading a document (`Builder`, media deduplicated by key, track placement bounded at 256 tracks) and the bounds every import ends with (`Imported::bound`: implausible rates and start timecodes reset and reported). |
|
||||
| `ale` | Avid Log Exchange: `AleDoc` (heading, columns, rows), `parse`, `write`, the standard heading and columns. |
|
||||
| `edl` | CMX 3600 edit decision lists: `parse` to the list as written (events, their lines and timecode fields, `M2`, `AUD`, clip-name, source-file, effect-name and locator comments), `write` for events the caller built, channel codes, reel names, the frame-rate guess. |
|
||||
| crate root | `Format` (EDL, xmeml, FCPXML, OTIO, AAF, OMF), `detect_text`, `decode_text`. |
|
||||
|
||||
No file I/O. Each format module gives the document as the format writes it; mapping it onto an
|
||||
application's timeline (which media a reel is, what a dissolve overlaps) stays in the application.
|
||||
No file I/O. EDL and ALE give the document as the format writes it. The formats that describe a
|
||||
whole edit read into and write from `model`; mapping `model` onto an application's own project
|
||||
stays in the application.
|
||||
AAF and OMF are in `craft-aaf`.
|
||||
|
||||
Feature `serde`: `Serialize` / `Deserialize` for the time, report and `Format` types.
|
||||
|
||||
@@ -0,0 +1,500 @@
|
||||
//! Building a [`Project`] from a document, and the helpers importers and exporters share.
|
||||
|
||||
use std::collections::HashMap;
|
||||
|
||||
use crate::model::{
|
||||
AudioStreamInfo, BinId, Catalog, ClipId, EffectInstance, EffectKind, Generator, ItemId, ItemKind, Label, MediaClip, MediaInfo, MediaKind,
|
||||
MediaRef, Param, ParamValue, Project, Sequence, SequenceSettings, Track, TrackId, TrackItem, TrackKind, VideoStreamInfo,
|
||||
};
|
||||
use crate::path::extension;
|
||||
use crate::rate::plausible_rate;
|
||||
use crate::report::Report;
|
||||
use crate::time::{FrameRate, Tick};
|
||||
|
||||
/// A timeline import may not materialise an unbounded number of sparse track
|
||||
/// labels. This remains well above common editorial timelines while keeping
|
||||
/// hostile lane/channel numbers from allocating every intervening track.
|
||||
pub const MAX_IMPORTED_TRACKS: usize = 256;
|
||||
|
||||
/// Start timecodes beyond this many frames (about 100 hours at the highest accepted rate) are not
|
||||
/// timecodes: a value near `i64::MAX` made an export overflow adding frame numbers to it.
|
||||
pub const MAX_START_TIMECODE: u64 = 360_000_000_000;
|
||||
|
||||
/// The result of an import: a standalone project.
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
pub struct Imported {
|
||||
/// Bins, media items and sequences read from the document (ids local to this project).
|
||||
pub project: Project,
|
||||
/// Top-level sequences, in document order (nested sequences are items too but not listed here).
|
||||
pub sequences: Vec<ItemId>,
|
||||
}
|
||||
|
||||
impl Imported {
|
||||
/// Reset what a document carries beyond what frame arithmetic can hold, and say so: frame rates
|
||||
/// above [`crate::rate::MAX_RATE`] per second and start timecodes beyond [`MAX_START_TIMECODE`]
|
||||
/// frames. Every importer ends with this pass, so no format can let such a number through.
|
||||
pub fn bound(&mut self, report: &mut Report) {
|
||||
for item in self.project.items.values_mut() {
|
||||
let name = item.name.clone();
|
||||
if let Some(seq) = item.as_sequence_mut() {
|
||||
if !plausible_rate(seq.settings.frame_rate) {
|
||||
report.warn(format!("sequence \"{name}\" has an implausible frame rate ({}); the default was used", seq.settings.frame_rate));
|
||||
seq.settings.frame_rate = FrameRate::default();
|
||||
}
|
||||
if seq.start_timecode.unsigned_abs() > MAX_START_TIMECODE {
|
||||
report.warn(format!("sequence \"{name}\" has an out-of-range start timecode ({} frames); it starts at 0", seq.start_timecode));
|
||||
seq.start_timecode = 0;
|
||||
}
|
||||
} else if let Some(media) = item.as_media_mut() {
|
||||
if let Some(v) = media.info.video.as_mut()
|
||||
&& !plausible_rate(v.frame_rate)
|
||||
{
|
||||
report.warn(format!("media \"{name}\" has an implausible frame rate ({}); the default was used", v.frame_rate));
|
||||
v.frame_rate = FrameRate::default();
|
||||
}
|
||||
if media.info.start_timecode.is_some_and(|tc| tc.unsigned_abs() > MAX_START_TIMECODE) {
|
||||
report.warn(format!("media \"{name}\" has an out-of-range start timecode; it was ignored"));
|
||||
media.info.start_timecode = None;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Media kind guess from a file extension.
|
||||
pub fn kind_from_ext(path: &str) -> MediaKind {
|
||||
match extension(path).as_str() {
|
||||
"wav" | "aif" | "aiff" | "mp3" | "aac" | "m4a" | "flac" | "ogg" | "opus" | "bwf" => MediaKind::AudioOnly,
|
||||
"png" | "jpg" | "jpeg" | "tif" | "tiff" | "psd" | "bmp" | "gif" | "webp" | "exr" | "dpx" | "tga" => MediaKind::Still,
|
||||
_ => MediaKind::Movie,
|
||||
}
|
||||
}
|
||||
|
||||
/// What a document says about a media file.
|
||||
#[derive(Clone, Debug, Default)]
|
||||
pub struct MediaSpec {
|
||||
pub duration: Option<Tick>,
|
||||
/// (width, height, rate).
|
||||
pub video: Option<(u32, u32, FrameRate)>,
|
||||
/// (sample rate, channels).
|
||||
pub audio: Option<(u32, u32)>,
|
||||
pub start_tc: Option<i64>,
|
||||
pub kind: Option<MediaKind>,
|
||||
}
|
||||
|
||||
pub fn media_info(name: &str, spec: &MediaSpec) -> MediaInfo {
|
||||
let kind = spec.kind.unwrap_or(match (spec.video.is_some(), spec.audio.is_some()) {
|
||||
(false, true) => MediaKind::AudioOnly,
|
||||
_ => MediaKind::Movie,
|
||||
});
|
||||
MediaInfo {
|
||||
name: name.to_string(),
|
||||
kind,
|
||||
duration: spec.duration.unwrap_or(Tick::ZERO),
|
||||
video: spec.video.map(|(w, h, r)| VideoStreamInfo { width: w, height: h, frame_rate: r, par: (1, 1) }),
|
||||
audio_streams: spec.audio.map(|(sr, ch)| AudioStreamInfo { sample_rate: sr, channels: ch, bits_per_sample: None }).into_iter().collect(),
|
||||
start_timecode: spec.start_tc,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn media_clip(media: MediaRef, info: MediaInfo) -> MediaClip {
|
||||
MediaClip { media, info, interpret_frame_rate: None, markers: Vec::new(), offline: false }
|
||||
}
|
||||
|
||||
/// The file path behind an item (following subclips), if it is file media.
|
||||
pub fn item_path(p: &Project, id: ItemId) -> Option<&str> {
|
||||
match &item_media(p, id)?.media {
|
||||
MediaRef::File { path } => Some(path),
|
||||
MediaRef::Generator(_) => None,
|
||||
}
|
||||
}
|
||||
|
||||
/// The media clip behind an item (following subclips).
|
||||
pub fn item_media(p: &Project, id: ItemId) -> Option<&MediaClip> {
|
||||
p.resolve_media(id).map(|(_, m, _)| m)
|
||||
}
|
||||
|
||||
/// The underlying item for exports (subclip → parent media).
|
||||
pub fn base_item(p: &Project, id: ItemId) -> ItemId {
|
||||
let mut cur = id;
|
||||
for _ in 0..16 {
|
||||
match p.item(cur).map(|i| &i.kind) {
|
||||
Some(ItemKind::Subclip { parent, .. }) => cur = *parent,
|
||||
_ => return cur,
|
||||
}
|
||||
}
|
||||
cur
|
||||
}
|
||||
|
||||
/// How an export report names a clip whose source no format here can carry (a title, an
|
||||
/// adjustment layer): `graphic clip "Title" at frame 48`. `None` for every other clip. A lost title
|
||||
/// must be named, not folded into a count.
|
||||
pub fn uncarried_clip(p: &Project, c: &TrackItem, rate: FrameRate) -> Option<String> {
|
||||
let ItemKind::Uncarried { what, .. } = &p.item(c.item)?.kind else { return None };
|
||||
Some(format!("{what} \"{}\" at frame {}", c.name, rate.frame_at(c.start)))
|
||||
}
|
||||
|
||||
pub fn generator_of(p: &Project, id: ItemId) -> Option<&Generator> {
|
||||
match &item_media(p, id)?.media {
|
||||
MediaRef::Generator(g) => Some(g),
|
||||
MediaRef::File { .. } => None,
|
||||
}
|
||||
}
|
||||
|
||||
/// Builds the project of an import.
|
||||
pub struct Builder<'c> {
|
||||
pub p: Project,
|
||||
pub catalog: &'c dyn Catalog,
|
||||
media: HashMap<String, ItemId>,
|
||||
pub top: Vec<ItemId>,
|
||||
next_link: u64,
|
||||
}
|
||||
|
||||
impl<'c> Builder<'c> {
|
||||
pub fn new(name: &str, catalog: &'c dyn Catalog) -> Self {
|
||||
Self { p: Project::new(name), catalog, media: HashMap::new(), top: Vec::new(), next_link: 1 }
|
||||
}
|
||||
|
||||
pub fn bin(&mut self, name: &str, parent: Option<BinId>) -> BinId {
|
||||
self.p.add_bin(name, parent)
|
||||
}
|
||||
|
||||
pub fn find_media(&self, key: &str) -> Option<ItemId> {
|
||||
self.media.get(key).copied()
|
||||
}
|
||||
|
||||
pub fn register_media(&mut self, key: &str, id: ItemId) {
|
||||
self.media.insert(key.to_string(), id);
|
||||
}
|
||||
|
||||
/// File media deduplicated by `key` (normally the resolved path).
|
||||
pub fn file_media(&mut self, key: &str, name: &str, path: &str, spec: &MediaSpec, bin: Option<BinId>) -> ItemId {
|
||||
if let Some(id) = self.media.get(key) {
|
||||
return *id;
|
||||
}
|
||||
let info = media_info(name, spec);
|
||||
let id = self.p.add_item(name, Label::Iris, ItemKind::Media(Box::new(media_clip(MediaRef::File { path: path.to_string() }, info))), bin);
|
||||
self.media.insert(key.to_string(), id);
|
||||
id
|
||||
}
|
||||
|
||||
pub fn generator_media(&mut self, key: &str, name: &str, g: Generator, spec: &MediaSpec, bin: Option<BinId>) -> ItemId {
|
||||
if let Some(id) = self.media.get(key) {
|
||||
return *id;
|
||||
}
|
||||
let mut spec = spec.clone();
|
||||
spec.kind = Some(MediaKind::Synthetic);
|
||||
let info = media_info(name, &spec);
|
||||
let id = self.p.add_item(name, Label::Lavender, ItemKind::Media(Box::new(media_clip(MediaRef::Generator(g), info))), bin);
|
||||
self.media.insert(key.to_string(), id);
|
||||
id
|
||||
}
|
||||
|
||||
/// Add an empty sequence item (filled later with [`Builder::put_sequence`]).
|
||||
pub fn reserve_sequence(&mut self, name: &str, settings: SequenceSettings, bin: Option<BinId>) -> ItemId {
|
||||
let seq = empty_sequence(settings);
|
||||
self.p.add_item(name, Label::Forest, ItemKind::Sequence(Box::new(seq)), bin)
|
||||
}
|
||||
|
||||
pub fn put_sequence(&mut self, id: ItemId, mut seq: Sequence) {
|
||||
for t in seq.all_tracks_mut() {
|
||||
t.sort();
|
||||
}
|
||||
if let Some(s) = self.p.sequence_mut(id) {
|
||||
*s = seq;
|
||||
}
|
||||
}
|
||||
|
||||
pub fn track(&mut self, kind: TrackKind, index: usize) -> Track {
|
||||
let id = TrackId(self.p.alloc_id());
|
||||
let name = match kind {
|
||||
TrackKind::Video => format!("Video {}", index + 1),
|
||||
TrackKind::Audio => format!("Audio {}", index + 1),
|
||||
};
|
||||
Track::new(id, kind, name)
|
||||
}
|
||||
|
||||
/// Ensure `n` tracks of one kind exist. Returns false when an imported
|
||||
/// label would exceed the supported per-kind track count.
|
||||
pub fn ensure_tracks(&mut self, seq: &mut Sequence, kind: TrackKind, n: usize) -> bool {
|
||||
if n > MAX_IMPORTED_TRACKS {
|
||||
return false;
|
||||
}
|
||||
while seq.tracks(kind).len() < n {
|
||||
let i = seq.tracks(kind).len();
|
||||
let t = self.track(kind, i);
|
||||
seq.tracks_mut(kind).push(t);
|
||||
}
|
||||
true
|
||||
}
|
||||
|
||||
pub fn link_id(&mut self) -> u64 {
|
||||
let l = self.next_link;
|
||||
self.next_link += 1;
|
||||
l
|
||||
}
|
||||
|
||||
pub fn alloc(&mut self) -> u64 {
|
||||
self.p.alloc_id()
|
||||
}
|
||||
|
||||
/// A track item with the intrinsic effects of its track kind.
|
||||
pub fn clip(&mut self, item: ItemId, kind: TrackKind, name: &str, start: Tick, duration: Tick, source_in: Tick) -> TrackItem {
|
||||
let label = self.p.item(item).map(|i| i.label).unwrap_or(Label::Iris);
|
||||
TrackItem {
|
||||
id: ClipId(self.p.alloc_id()),
|
||||
item,
|
||||
name: name.to_string(),
|
||||
label,
|
||||
start,
|
||||
duration,
|
||||
source_in,
|
||||
speed: 1.0,
|
||||
reverse: false,
|
||||
enabled: true,
|
||||
link: None,
|
||||
group: None,
|
||||
effects: self.catalog.intrinsic(kind),
|
||||
markers: Vec::new(),
|
||||
gain_db: 0.0,
|
||||
frame_hold: None,
|
||||
scale_to_frame: false,
|
||||
source_channels: Vec::new(),
|
||||
ext: None,
|
||||
}
|
||||
}
|
||||
|
||||
/// Place `item` on the first track at or after `preferred` where it does not overlap anything,
|
||||
/// creating tracks as needed. Returns the track index.
|
||||
pub fn place(&mut self, seq: &mut Sequence, kind: TrackKind, preferred: usize, item: TrackItem) -> Option<usize> {
|
||||
let mut i = preferred;
|
||||
loop {
|
||||
if i >= MAX_IMPORTED_TRACKS || !self.ensure_tracks(seq, kind, i + 1) {
|
||||
return None;
|
||||
}
|
||||
let t = &mut seq.tracks_mut(kind)[i];
|
||||
if !t.items.iter().any(|o| o.start < item.end() && item.start < o.end()) {
|
||||
t.items.push(item);
|
||||
t.sort();
|
||||
return Some(i);
|
||||
}
|
||||
i += 1;
|
||||
}
|
||||
}
|
||||
|
||||
/// Finish: grow media durations to cover every use, return the fragment.
|
||||
pub fn finish(mut self) -> Imported {
|
||||
let mut need: HashMap<ItemId, Tick> = HashMap::new();
|
||||
for it in self.p.items.values() {
|
||||
if let Some(seq) = it.as_sequence() {
|
||||
for t in seq.all_tracks() {
|
||||
for ti in &t.items {
|
||||
let e = need.entry(ti.item).or_insert(Tick::ZERO);
|
||||
*e = (*e).max(ti.source_out()).max(ti.source_in + Tick(1));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
for (id, end) in need {
|
||||
if let Some(m) = self.p.item_mut(id).and_then(|i| i.as_media_mut())
|
||||
&& m.info.duration < end
|
||||
{
|
||||
m.info.duration = end;
|
||||
}
|
||||
}
|
||||
Imported { project: self.p, sequences: self.top }
|
||||
}
|
||||
}
|
||||
|
||||
pub fn empty_sequence(settings: SequenceSettings) -> Sequence {
|
||||
Sequence::new(settings)
|
||||
}
|
||||
|
||||
pub fn settings_for(rate: FrameRate, width: u32, height: u32, drop_frame: bool) -> SequenceSettings {
|
||||
SequenceSettings { width, height, frame_rate: rate, drop_frame: drop_frame && rate.supports_drop_frame(), ..SequenceSettings::default() }
|
||||
}
|
||||
|
||||
/// Source time advanced by a timeline duration at `speed` (exact for integral speeds).
|
||||
///
|
||||
/// This is the one place the importers multiply a `Tick` by a number from a file: `Tick` has no
|
||||
/// `Mul`, and its `Add`/`Sub` saturate, so every other sum of durations already clamps. It saturates
|
||||
/// at the `i64` bounds like those operators do (not at `Tick::MAX`, which the editing code uses as
|
||||
/// its "no next item" sentinel).
|
||||
pub fn scaled(d: Tick, speed: f64) -> Tick {
|
||||
let s = speed.abs();
|
||||
// saturating: a duration read from a file times a speed from the same file can exceed an i64 of ticks
|
||||
if s == s.round() && s < 1e6 { Tick(d.0.saturating_mul(s as i64)) } else { Tick((d.0 as f64 * s).round() as i64) }
|
||||
}
|
||||
|
||||
/// Timeline duration of a source duration at `speed`.
|
||||
pub fn unscaled(d: Tick, speed: f64) -> Tick {
|
||||
let s = speed.abs();
|
||||
if s == 0.0 {
|
||||
return d;
|
||||
}
|
||||
if s == 1.0 { d } else { Tick((d.0 as f64 / s).round() as i64) }
|
||||
}
|
||||
|
||||
pub fn param<'a>(ti: &'a TrackItem, effect: &str, param: &str) -> Option<&'a Param> {
|
||||
ti.effect(effect).and_then(|e| e.param(param))
|
||||
}
|
||||
|
||||
/// Set a parameter, adding the effect instance (defaults) if the item lacks it.
|
||||
pub fn set_param(catalog: &dyn Catalog, ti: &mut TrackItem, effect: &str, param: &str, value: Param) {
|
||||
if ti.effect(effect).is_none()
|
||||
&& let Some(d) = catalog.effect(effect)
|
||||
{
|
||||
ti.effects.push(d.instance());
|
||||
}
|
||||
if let Some(e) = ti.effect_mut(effect) {
|
||||
e.params.insert(param.to_string(), value);
|
||||
}
|
||||
}
|
||||
|
||||
/// Whether a parameter differs from its definition default or is animated.
|
||||
pub fn param_modified(catalog: &dyn Catalog, ti: &TrackItem, effect: &str, param_id: &str) -> bool {
|
||||
let Some(p) = param(ti, effect, param_id) else { return false };
|
||||
if p.is_animated() {
|
||||
return true;
|
||||
}
|
||||
let def = catalog.effect(effect).and_then(|d| d.param(param_id).map(|p| p.default.clone()));
|
||||
match (&p.value, def) {
|
||||
(ParamValue::Vec2(v), Some(ParamValue::Vec2(_))) => !(v.x.is_nan() && v.y.is_nan()),
|
||||
(v, Some(d)) => *v != d,
|
||||
_ => false,
|
||||
}
|
||||
}
|
||||
|
||||
/// Non-intrinsic effects on an item.
|
||||
pub fn standard_effects<'a>(catalog: &'a dyn Catalog, ti: &'a TrackItem) -> impl Iterator<Item = &'a EffectInstance> {
|
||||
ti.effects.iter().filter(move |e| !catalog.effect(&e.effect).is_some_and(|d| d.intrinsic))
|
||||
}
|
||||
|
||||
/// A transition effect for a document's transition name. Unknown names become Cross Dissolve
|
||||
/// (Constant Power on audio) with a warning.
|
||||
pub fn transition_effect(catalog: &dyn Catalog, name: &str, audio: bool, report: &mut Report) -> EffectInstance {
|
||||
let n = name.trim().to_ascii_lowercase();
|
||||
let alias = match n.as_str() {
|
||||
"dissolve" | "cross dissolve" | "smpte_dissolve" | "d" | "crossdissolve" => Some(if audio { "constant_power" } else { "cross_dissolve" }),
|
||||
"cross fade (+3db)" | "cross fade +3db" | "crossfade (+3db)" | "cross fade" => Some("constant_power"),
|
||||
"cross fade (0db)" | "cross fade 0db" | "crossfade (0db)" => Some("constant_gain"),
|
||||
"dip to color dissolve" | "fade in fade out dissolve" | "fade to color" => Some("dip_to_black"),
|
||||
"edge wipe" | "wipe" | "smpte_wipe" => Some("wipe"),
|
||||
_ => None,
|
||||
};
|
||||
if let Some(id) = alias
|
||||
&& let Some(d) = catalog.effect(id)
|
||||
{
|
||||
return d.instance();
|
||||
}
|
||||
let want = if audio { EffectKind::AudioTransition } else { EffectKind::VideoTransition };
|
||||
if let Some(d) = catalog.effect_by_name(name.trim()).filter(|d| d.kind == want) {
|
||||
return d.instance();
|
||||
}
|
||||
let fallback = if audio { "constant_power" } else { "cross_dissolve" };
|
||||
let def = catalog.effect(fallback);
|
||||
report.warn(format!("transition \"{name}\" is not supported; imported as {}", def.as_ref().map(|d| d.name.as_str()).unwrap_or(fallback)));
|
||||
def.map(|d| d.instance()).unwrap_or_else(|| EffectInstance { effect: fallback.into(), enabled: true, params: Default::default(), ext: None })
|
||||
}
|
||||
|
||||
/// Name of a transition in a document (the catalog's stable name, so a rename in an application's
|
||||
/// interface does not change the file).
|
||||
pub fn transition_name(catalog: &dyn Catalog, e: &EffectInstance) -> String {
|
||||
catalog.effect(&e.effect).map(|d| d.name).unwrap_or_else(|| e.effect.clone())
|
||||
}
|
||||
|
||||
/// Label from a document label name (the sixteen label names; names such as "Good Take" are no label).
|
||||
pub fn label_from(s: &str) -> Option<Label> {
|
||||
Label::from_name(s.trim())
|
||||
}
|
||||
|
||||
/// dB ↔ linear gain.
|
||||
pub fn db_to_gain(db: f64) -> f64 {
|
||||
if db <= -287.0 { 0.0 } else { 10f64.powf(db / 20.0) }
|
||||
}
|
||||
pub fn gain_to_db(g: f64) -> f64 {
|
||||
if g <= 1e-15 { -287.5 } else { 20.0 * g.log10() }
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::fx::Standard;
|
||||
use crate::time::TimeRange;
|
||||
|
||||
#[test]
|
||||
fn imported_tracks_are_bounded() {
|
||||
let mut b = Builder::new("tracks", &Standard);
|
||||
let mut seq = empty_sequence(Default::default());
|
||||
assert!(b.ensure_tracks(&mut seq, TrackKind::Video, MAX_IMPORTED_TRACKS));
|
||||
assert_eq!(seq.video_tracks.len(), MAX_IMPORTED_TRACKS);
|
||||
assert!(!b.ensure_tracks(&mut seq, TrackKind::Video, MAX_IMPORTED_TRACKS + 1));
|
||||
assert_eq!(seq.video_tracks.len(), MAX_IMPORTED_TRACKS);
|
||||
}
|
||||
|
||||
/// A huge duration at an integral speed saturates instead of overflowing (an EDL with a record-out
|
||||
/// timecode of 2147483647 hours and an `M2 ... 72.0` speed change panicked here).
|
||||
#[test]
|
||||
fn scaled_saturates() {
|
||||
assert_eq!(scaled(Tick(i64::MAX / 2), 3.0), Tick(i64::MAX));
|
||||
assert_eq!(scaled(Tick(i64::MIN / 2), 3.0), Tick(i64::MIN));
|
||||
assert_eq!(scaled(Tick(1000), 3.0), Tick(3000));
|
||||
assert_eq!(scaled(Tick(1000), 0.5), Tick(500));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn subclip_cycle_resolution_is_bounded() {
|
||||
let mut p = Project::new("Cycle Test");
|
||||
let spec = MediaSpec { duration: Some(Tick(100_000)), ..Default::default() };
|
||||
let media = media_clip(MediaRef::File { path: "/path/to/clip.mp4".into() }, media_info("clip.mp4", &spec));
|
||||
let media_id = p.add_item("Clip", Label::Iris, ItemKind::Media(Box::new(media)), None);
|
||||
let sub = |parent| ItemKind::Subclip { parent, range: TimeRange::default() };
|
||||
|
||||
// Normal subclip and chained subclip
|
||||
let sub1 = p.add_item("Sub 1", Label::Iris, ItemKind::Subclip { parent: media_id, range: TimeRange::new(Tick::ZERO, Tick(10_000)) }, None);
|
||||
let sub2 = p.add_item("Sub 2", Label::Iris, ItemKind::Subclip { parent: sub1, range: TimeRange::new(Tick(1_000), Tick(5_000)) }, None);
|
||||
assert_eq!(base_item(&p, media_id), media_id);
|
||||
assert_eq!(base_item(&p, sub1), media_id);
|
||||
assert_eq!(base_item(&p, sub2), media_id);
|
||||
assert_eq!(item_path(&p, sub2), Some("/path/to/clip.mp4"));
|
||||
assert_eq!(p.resolve_media(sub2).map(|(id, _, range)| (id, range)), Some((media_id, Some(TimeRange::new(Tick(1_000), Tick(5_000))))));
|
||||
|
||||
// Self-referencing subclip, and a mutual cycle (A -> B -> A)
|
||||
let self_cycle = p.add_item("Self Cycle", Label::Iris, sub(ItemId(9999)), None);
|
||||
p.item_mut(self_cycle).unwrap().kind = sub(self_cycle);
|
||||
let cycle_a = p.add_item("Cycle A", Label::Iris, sub(ItemId(9999)), None);
|
||||
let cycle_b = p.add_item("Cycle B", Label::Iris, sub(cycle_a), None);
|
||||
p.item_mut(cycle_a).unwrap().kind = sub(cycle_b);
|
||||
assert_eq!(base_item(&p, self_cycle), self_cycle);
|
||||
assert_eq!(item_media(&p, self_cycle), None);
|
||||
assert!(matches!(base_item(&p, cycle_a), x if x == cycle_a || x == cycle_b));
|
||||
assert_eq!(item_media(&p, cycle_a), None);
|
||||
assert_eq!(item_path(&p, cycle_b), None);
|
||||
assert_eq!(p.source_size(cycle_a), None);
|
||||
|
||||
// Missing parent
|
||||
let missing = p.add_item("Missing Parent", Label::Iris, sub(ItemId(123456789)), None);
|
||||
assert_eq!(base_item(&p, missing), ItemId(123456789));
|
||||
assert_eq!(item_media(&p, missing), None);
|
||||
}
|
||||
|
||||
/// An implausible rate or start timecode is reset, and the report says so.
|
||||
#[test]
|
||||
fn imports_are_bounded() {
|
||||
let mut b = Builder::new("bounds", &Standard);
|
||||
let id = b.reserve_sequence("S", settings_for(FrameRate::new(i64::MAX, 1), 1920, 1080, false), None);
|
||||
if let Some(s) = b.p.sequence_mut(id) {
|
||||
s.start_timecode = i64::MAX;
|
||||
}
|
||||
let spec = MediaSpec { video: Some((1920, 1080, FrameRate::new(i64::MAX, 1))), start_tc: Some(-i64::MAX), ..Default::default() };
|
||||
let m = b.file_media("m", "m.mov", "/m.mov", &spec, None);
|
||||
let mut imported = b.finish();
|
||||
let mut report = Report::default();
|
||||
imported.bound(&mut report);
|
||||
let s = imported.project.sequence(id).unwrap();
|
||||
assert_eq!((s.settings.frame_rate, s.start_timecode), (FrameRate::default(), 0));
|
||||
let media = imported.project.item(m).unwrap().as_media().unwrap();
|
||||
assert_eq!((media.info.frame_rate(), media.info.start_timecode), (FrameRate::default(), None));
|
||||
assert_eq!(report.warnings().count(), 4, "{report}");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,100 @@
|
||||
//! The effects the formats name themselves, and a [`Catalog`] of just those.
|
||||
//!
|
||||
//! Final Cut Pro 7 XML and FCPXML have their own elements for a clip's motion, opacity, speed,
|
||||
//! audio level and pan, and for the common dissolves. The readers and writers map those onto the
|
||||
//! effect and parameter ids below, so an application's [`Catalog`] has to answer for at least
|
||||
//! these ids (with whatever defaults it uses) and for anything else it wants carried.
|
||||
|
||||
use crate::model::{Catalog, EffectDef, EffectInstance, EffectKind, ParamDef, ParamValue, TrackKind, Vec2};
|
||||
|
||||
/// Motion: `position` and `anchor` (points in pixels; NaN = automatic, the centre), `scale` and
|
||||
/// `scale_width` (percent), `uniform_scale`, `rotation` (degrees), `anti_flicker` (0..=1).
|
||||
pub const MOTION: &str = "motion";
|
||||
/// Opacity: `opacity` (percent), `blend` (blend mode index).
|
||||
pub const OPACITY: &str = "opacity";
|
||||
/// Time remapping: `speed` (percent).
|
||||
pub const TIME_REMAP: &str = "time_remap";
|
||||
/// Audio level: `level` (dB).
|
||||
pub const VOLUME: &str = "volume";
|
||||
/// Per-channel level: `left`, `right` (dB).
|
||||
pub const CHANNEL_VOLUME: &str = "channel_volume";
|
||||
/// Pan: `balance` (−100..=100).
|
||||
pub const PANNER: &str = "panner";
|
||||
|
||||
fn def(id: &str, name: &str, kind: EffectKind, category: &str, intrinsic: bool, params: Vec<(&str, &str, ParamValue)>) -> EffectDef {
|
||||
EffectDef {
|
||||
id: id.into(),
|
||||
name: name.into(),
|
||||
kind,
|
||||
category: category.into(),
|
||||
intrinsic,
|
||||
params: params.into_iter().map(|(id, label, default)| ParamDef { id: id.into(), label: label.into(), default }).collect(),
|
||||
}
|
||||
}
|
||||
|
||||
/// The definitions of [`Standard`], in order.
|
||||
pub fn standard_defs() -> Vec<EffectDef> {
|
||||
use EffectKind::{Audio, AudioTransition, Video, VideoTransition};
|
||||
use ParamValue::{Bool, Choice, Float};
|
||||
let auto = || ParamValue::Vec2(Vec2::new(f64::NAN, f64::NAN));
|
||||
vec![
|
||||
def(
|
||||
MOTION,
|
||||
"Motion",
|
||||
Video,
|
||||
"",
|
||||
true,
|
||||
vec![
|
||||
("position", "Position", auto()),
|
||||
("scale", "Scale", Float(100.0)),
|
||||
("scale_width", "Scale Width", Float(100.0)),
|
||||
("uniform_scale", "Uniform Scale", Bool(true)),
|
||||
("rotation", "Rotation", Float(0.0)),
|
||||
("anchor", "Anchor Point", auto()),
|
||||
("anti_flicker", "Anti-flicker Filter", Float(0.0)),
|
||||
],
|
||||
),
|
||||
def(OPACITY, "Opacity", Video, "", true, vec![("opacity", "Opacity", Float(100.0)), ("blend", "Blend Mode", Choice(0))]),
|
||||
def(TIME_REMAP, "Time Remapping", Video, "", true, vec![("speed", "Speed", Float(100.0))]),
|
||||
def(VOLUME, "Volume", Audio, "", true, vec![("bypass", "Bypass", Bool(false)), ("level", "Level", Float(0.0))]),
|
||||
def(
|
||||
CHANNEL_VOLUME,
|
||||
"Channel Volume",
|
||||
Audio,
|
||||
"",
|
||||
true,
|
||||
vec![("bypass", "Bypass", Bool(false)), ("left", "Left", Float(0.0)), ("right", "Right", Float(0.0))],
|
||||
),
|
||||
def(PANNER, "Panner", Audio, "", true, vec![("balance", "Balance", Float(0.0))]),
|
||||
def("cross_dissolve", "Cross Dissolve", VideoTransition, "Dissolve", false, vec![]),
|
||||
def("dip_to_black", "Dip to Black", VideoTransition, "Dissolve", false, vec![]),
|
||||
def("film_dissolve", "Film Dissolve", VideoTransition, "Dissolve", false, vec![]),
|
||||
def("additive_dissolve", "Additive Dissolve", VideoTransition, "Dissolve", false, vec![]),
|
||||
def("non_additive_dissolve", "Non-Additive Dissolve", VideoTransition, "Video Transitions", false, vec![]),
|
||||
def("wipe", "Wipe", VideoTransition, "Video Transitions", false, vec![("direction", "Direction", Choice(3))]),
|
||||
def("constant_power", "Constant Power", AudioTransition, "Crossfade", false, vec![]),
|
||||
def("constant_gain", "Constant Gain", AudioTransition, "Crossfade", false, vec![]),
|
||||
def("exponential_fade", "Exponential Fade", AudioTransition, "Crossfade", false, vec![]),
|
||||
]
|
||||
}
|
||||
|
||||
/// A catalog of the effects in this module and nothing else: enough for an application without
|
||||
/// an effect registry of its own, and for tests. Names match in any case.
|
||||
#[derive(Clone, Copy, Debug, Default)]
|
||||
pub struct Standard;
|
||||
|
||||
impl Catalog for Standard {
|
||||
fn effect(&self, id: &str) -> Option<EffectDef> {
|
||||
standard_defs().into_iter().find(|d| d.id == id)
|
||||
}
|
||||
fn effect_by_name(&self, name: &str) -> Option<EffectDef> {
|
||||
standard_defs().into_iter().find(|d| d.name.eq_ignore_ascii_case(name))
|
||||
}
|
||||
fn intrinsic(&self, kind: TrackKind) -> Vec<EffectInstance> {
|
||||
let ids: &[&str] = match kind {
|
||||
TrackKind::Video => &[MOTION, OPACITY, TIME_REMAP],
|
||||
TrackKind::Audio => &[VOLUME, CHANNEL_VOLUME, PANNER],
|
||||
};
|
||||
ids.iter().filter_map(|id| self.effect(id)).map(|d| d.instance()).collect()
|
||||
}
|
||||
}
|
||||
@@ -7,6 +7,10 @@
|
||||
//! floating rates).
|
||||
//! - [`report`]: what an import or export could not represent exactly.
|
||||
//! - [`path`]: media references (`file://` URLs, absolute and relative paths), lexically.
|
||||
//! - [`model`]: a neutral timeline (bins, media, sequences, tracks, clips, transitions, markers,
|
||||
//! effects with keyframes) for the formats that describe a whole edit, with [`model::Catalog`]
|
||||
//! for the application's effects. [`fx`] has the effects the formats name themselves; [`build`]
|
||||
//! builds a [`model::Project`] while reading.
|
||||
//! - [`ale`]: Avid Log Exchange clip logs.
|
||||
//! - [`edl`]: CMX 3600 edit decision lists.
|
||||
//! - [`Format`], [`detect_text`], [`decode_text`]: which interchange format a document is.
|
||||
@@ -16,12 +20,16 @@
|
||||
//! the caller's.
|
||||
|
||||
pub mod ale;
|
||||
pub mod build;
|
||||
pub mod edl;
|
||||
pub mod fx;
|
||||
pub mod model;
|
||||
pub mod path;
|
||||
pub mod rate;
|
||||
pub mod report;
|
||||
pub mod time;
|
||||
|
||||
pub use build::Imported;
|
||||
pub use report::{Level, Report, ReportEntry};
|
||||
pub use time::{FrameRate, Tick};
|
||||
|
||||
@@ -84,6 +92,61 @@ impl Format {
|
||||
}
|
||||
}
|
||||
|
||||
/// Error from an importer or exporter. Unsupported *features* never error; they go in the [`Report`].
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub enum Error {
|
||||
/// The document cannot be read as its format.
|
||||
Parse {
|
||||
format: &'static str,
|
||||
message: String,
|
||||
},
|
||||
/// The document contains no timeline.
|
||||
Empty,
|
||||
/// The project has no sequence with this id.
|
||||
NoSequence(model::ItemId),
|
||||
Other(String),
|
||||
}
|
||||
|
||||
impl std::fmt::Display for Error {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
match self {
|
||||
Error::Parse { format, message } => write!(f, "{format} parse error: {message}"),
|
||||
Error::Empty => f.write_str("the document contains no timeline"),
|
||||
Error::NoSequence(id) => write!(f, "no sequence {id:?} in the project"),
|
||||
Error::Other(message) => f.write_str(message),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl std::error::Error for Error {}
|
||||
|
||||
pub type Result<T> = std::result::Result<T, Error>;
|
||||
|
||||
/// How to read a timeline document.
|
||||
#[derive(Clone, Copy)]
|
||||
pub struct ImportOptions<'a> {
|
||||
/// Directory relative media paths are resolved against (normally the document's directory).
|
||||
pub base_dir: Option<&'a str>,
|
||||
/// The application's effects and generators (see [`model::Catalog`]; [`fx::Standard`] knows
|
||||
/// only what the formats name themselves).
|
||||
pub catalog: &'a dyn model::Catalog,
|
||||
/// The application's name for what it writes into documents for itself: a generator's payload
|
||||
/// in xmeml is `<namespace>:<payload>`, and OpenTimelineIO metadata sits under this key.
|
||||
pub namespace: &'a str,
|
||||
}
|
||||
|
||||
/// How to write a timeline document.
|
||||
#[derive(Clone, Copy)]
|
||||
pub struct ExportOptions<'a> {
|
||||
/// Write media paths relative to this directory where the format allows it.
|
||||
pub relative_to: Option<&'a str>,
|
||||
/// Document name (default: the sequence's).
|
||||
pub name: Option<&'a str>,
|
||||
pub catalog: &'a dyn model::Catalog,
|
||||
/// See [`ImportOptions::namespace`].
|
||||
pub namespace: &'a str,
|
||||
}
|
||||
|
||||
/// Sniff which text format (EDL, xmeml, FCPXML, OTIO) a document is from its bytes, using the file
|
||||
/// extension as a hint. The binary formats (AAF, OMF) have their own sniffers; try those first.
|
||||
pub fn detect_text(bytes: &[u8], extension: Option<&str>) -> Option<Format> {
|
||||
|
||||
@@ -0,0 +1,910 @@
|
||||
//! A neutral timeline: what Final Cut Pro 7 XML, FCPXML and OpenTimelineIO documents describe.
|
||||
//!
|
||||
//! A [`Project`] holds a tree of bins and a flat map of items: media (a file or a generator, with
|
||||
//! what a document says about it), sequences, and subclips. A [`Sequence`] has video and audio
|
||||
//! [`Track`]s of [`TrackItem`]s (clip instances with speed, reverse, frame hold, links, effects
|
||||
//! with keyframes, markers) and [`Transition`]s. Timeline positions are sequence ticks;
|
||||
//! `source_in` is media time.
|
||||
//!
|
||||
//! The shape is the one an editor's project has, cut down to what the formats carry. An
|
||||
//! application converts its own project to and from it. Three things stay the application's:
|
||||
//!
|
||||
//! - **Effects.** An [`EffectInstance`] is an id with parameters. The formats know a few ids
|
||||
//! themselves (see [`fx`]); every other effect is looked up through a [`Catalog`] the caller
|
||||
//! supplies.
|
||||
//! - **Generators** other than the ones every editor has: [`Generator::Other`] carries a name and
|
||||
//! an opaque payload.
|
||||
//! - **Extensions.** `ext` on items, sequences, tracks, clips, transitions and markers is JSON
|
||||
//! text the application owns. A format that can carry application metadata (OpenTimelineIO)
|
||||
//! writes it under the application's namespace and gives it back on reading; the others ignore it.
|
||||
|
||||
use std::collections::BTreeMap;
|
||||
|
||||
use crate::time::{FrameRate, Tick, TimeRange};
|
||||
|
||||
macro_rules! id_type {
|
||||
($(#[$doc:meta])* $name:ident) => {
|
||||
$(#[$doc])*
|
||||
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, PartialOrd, Ord, Hash)]
|
||||
pub struct $name(pub u64);
|
||||
};
|
||||
}
|
||||
|
||||
id_type!(
|
||||
/// A project item (media, sequence, subclip).
|
||||
ItemId
|
||||
);
|
||||
id_type!(BinId);
|
||||
id_type!(TrackId);
|
||||
id_type!(
|
||||
/// A clip instance on a track.
|
||||
ClipId
|
||||
);
|
||||
id_type!(TransitionId);
|
||||
id_type!(MarkerId);
|
||||
|
||||
/// A point or size in pixels (or a fraction, where a parameter says so).
|
||||
#[derive(Clone, Copy, Debug, Default, PartialEq)]
|
||||
pub struct Vec2 {
|
||||
pub x: f64,
|
||||
pub y: f64,
|
||||
}
|
||||
|
||||
impl Vec2 {
|
||||
pub const fn new(x: f64, y: f64) -> Vec2 {
|
||||
Vec2 { x, y }
|
||||
}
|
||||
}
|
||||
|
||||
/// Application JSON carried through a document unread (see the module docs).
|
||||
pub type Ext = Option<String>;
|
||||
|
||||
/// The sixteen label colours of Premiere Pro, which FCP7 XML writes by name (`label2`).
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
|
||||
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",
|
||||
}
|
||||
}
|
||||
|
||||
/// The label of a name, in any case.
|
||||
pub fn from_name(s: &str) -> Option<Label> {
|
||||
Self::ALL.iter().copied().find(|l| l.name().eq_ignore_ascii_case(s))
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------------------------
|
||||
// Media
|
||||
// ---------------------------------------------------------------------------------------------
|
||||
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
|
||||
pub enum MediaKind {
|
||||
Movie,
|
||||
AudioOnly,
|
||||
Still,
|
||||
ImageSequence,
|
||||
/// A generator.
|
||||
Synthetic,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
pub struct VideoStreamInfo {
|
||||
pub width: u32,
|
||||
pub height: u32,
|
||||
pub frame_rate: FrameRate,
|
||||
/// Pixel aspect ratio.
|
||||
pub par: (u32, u32),
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
pub struct AudioStreamInfo {
|
||||
pub sample_rate: u32,
|
||||
pub channels: u32,
|
||||
pub bits_per_sample: Option<u32>,
|
||||
}
|
||||
|
||||
/// What a document says about a piece of media.
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
pub struct MediaInfo {
|
||||
pub name: String,
|
||||
pub kind: MediaKind,
|
||||
pub duration: Tick,
|
||||
pub video: Option<VideoStreamInfo>,
|
||||
pub audio_streams: Vec<AudioStreamInfo>,
|
||||
/// Timecode of the first frame, in frames at the media's rate.
|
||||
pub start_timecode: Option<i64>,
|
||||
}
|
||||
|
||||
impl MediaInfo {
|
||||
pub fn has_video(&self) -> bool {
|
||||
self.video.is_some()
|
||||
}
|
||||
pub fn has_audio(&self) -> bool {
|
||||
!self.audio_streams.is_empty()
|
||||
}
|
||||
/// The first audio stream.
|
||||
pub fn audio(&self) -> Option<&AudioStreamInfo> {
|
||||
self.audio_streams.first()
|
||||
}
|
||||
/// The picture's rate, else the default rate.
|
||||
pub fn frame_rate(&self) -> FrameRate {
|
||||
self.video.as_ref().map(|v| v.frame_rate).unwrap_or_default()
|
||||
}
|
||||
}
|
||||
|
||||
/// Synthetic media.
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
pub enum Generator {
|
||||
BlackVideo,
|
||||
/// A solid colour (RGBA, 0..=1).
|
||||
ColorMatte {
|
||||
color: [f32; 4],
|
||||
},
|
||||
TransparentVideo,
|
||||
CountingLeader,
|
||||
BarsAndTone,
|
||||
/// A generator only the application knows: the name a document shows for it, and a payload
|
||||
/// the application reads back (formats that can, write it next to the name).
|
||||
Other {
|
||||
name: String,
|
||||
data: String,
|
||||
},
|
||||
}
|
||||
|
||||
impl Generator {
|
||||
/// The name a document shows for it.
|
||||
pub fn label(&self) -> String {
|
||||
match self {
|
||||
Generator::BlackVideo => "Black Video".into(),
|
||||
Generator::ColorMatte { .. } => "Color Matte".into(),
|
||||
Generator::TransparentVideo => "Transparent Video".into(),
|
||||
Generator::CountingLeader => "Universal Counting Leader".into(),
|
||||
Generator::BarsAndTone => "Bars and Tone".into(),
|
||||
Generator::Other { name, .. } => name.clone(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
pub enum MediaRef {
|
||||
File { path: String },
|
||||
Generator(Generator),
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
pub struct MediaClip {
|
||||
pub media: MediaRef,
|
||||
pub info: MediaInfo,
|
||||
/// A frame rate that overrides the file's (the editor's "interpret footage").
|
||||
pub interpret_frame_rate: Option<FrameRate>,
|
||||
pub markers: Vec<Marker>,
|
||||
/// The file is known to be missing.
|
||||
pub offline: bool,
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------------------------
|
||||
// Items and bins
|
||||
// ---------------------------------------------------------------------------------------------
|
||||
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
pub enum ItemKind {
|
||||
Media(Box<MediaClip>),
|
||||
Sequence(Box<Sequence>),
|
||||
/// A range of another item.
|
||||
Subclip {
|
||||
parent: ItemId,
|
||||
range: TimeRange,
|
||||
},
|
||||
/// Something no format here has an equivalent of (a title, an adjustment layer). Exports name
|
||||
/// each clip of one in the report (`<what> "<clip name>" at frame N`) and write what the
|
||||
/// format's module documents instead.
|
||||
Uncarried {
|
||||
what: String,
|
||||
width: u32,
|
||||
height: u32,
|
||||
rate: FrameRate,
|
||||
duration: Tick,
|
||||
},
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
pub struct ProjectItem {
|
||||
pub id: ItemId,
|
||||
pub name: String,
|
||||
pub label: Label,
|
||||
pub kind: ItemKind,
|
||||
/// Logging metadata by field name (`Scene`, `Tape Name`, …).
|
||||
pub metadata: BTreeMap<String, String>,
|
||||
pub ext: Ext,
|
||||
}
|
||||
|
||||
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(),
|
||||
_ => 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::Uncarried { duration, .. } => *duration,
|
||||
}
|
||||
}
|
||||
pub fn frame_rate(&self) -> FrameRate {
|
||||
match &self.kind {
|
||||
ItemKind::Media(m) => m.frame_rate(),
|
||||
ItemKind::Sequence(s) => s.settings.frame_rate,
|
||||
ItemKind::Uncarried { rate, .. } => *rate,
|
||||
ItemKind::Subclip { .. } => FrameRate::default(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
pub enum BinEntry {
|
||||
Item(ItemId),
|
||||
Bin(Bin),
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
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 })
|
||||
}
|
||||
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 })
|
||||
}
|
||||
/// Every item in this bin and the bins inside it, depth first.
|
||||
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 that directly holds item `id`.
|
||||
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
|
||||
}
|
||||
}
|
||||
|
||||
/// Bins, media and sequences. Ids are unique within one project.
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
pub struct Project {
|
||||
pub name: String,
|
||||
pub root: Bin,
|
||||
pub items: BTreeMap<ItemId, ProjectItem>,
|
||||
pub next_id: u64,
|
||||
pub ext: Ext,
|
||||
}
|
||||
|
||||
impl Project {
|
||||
pub fn new(name: &str) -> Self {
|
||||
Self { name: name.into(), root: Bin { id: BinId(0), name: name.into(), children: Vec::new() }, items: BTreeMap::new(), next_id: 1, ext: None }
|
||||
}
|
||||
|
||||
pub fn alloc_id(&mut self) -> u64 {
|
||||
let id = self.next_id;
|
||||
self.next_id = self.next_id.saturating_add(1);
|
||||
id
|
||||
}
|
||||
|
||||
/// Add an item to `bin` (the root when `None` or not found).
|
||||
pub fn add_item(&mut self, name: &str, label: Label, kind: ItemKind, bin: Option<BinId>) -> ItemId {
|
||||
let id = ItemId(self.alloc_id());
|
||||
self.items.insert(id, ProjectItem { id, name: name.into(), label, kind, metadata: BTreeMap::new(), ext: None });
|
||||
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 an item into `bin` (the root when `None`). False when the item or the bin is not there.
|
||||
pub fn move_item_to_bin(&mut self, item: ItemId, bin: Option<BinId>) -> bool {
|
||||
let target = bin.unwrap_or(self.root.id);
|
||||
if !self.items.contains_key(&item) || self.root.find_bin(target).is_none() || !self.root.remove_item(item) {
|
||||
return false;
|
||||
}
|
||||
match self.root.find_bin_mut(target) {
|
||||
Some(t) => t.children.push(BinEntry::Item(item)),
|
||||
None => self.root.children.push(BinEntry::Item(item)),
|
||||
}
|
||||
true
|
||||
}
|
||||
|
||||
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)
|
||||
}
|
||||
|
||||
/// The media behind an item, through at most sixteen subclips, with the outermost subclip's
|
||||
/// range. Missing parents, cycles and longer chains are no 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 subclip has the size of its parent.
|
||||
pub fn source_size(&self, item: ItemId) -> Option<(u32, u32)> {
|
||||
let mut id = item;
|
||||
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::Uncarried { width, height, .. } => Some((*width, *height)),
|
||||
ItemKind::Subclip { parent, .. } => {
|
||||
id = *parent;
|
||||
continue;
|
||||
}
|
||||
};
|
||||
}
|
||||
None
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------------------------
|
||||
// Sequences
|
||||
// ---------------------------------------------------------------------------------------------
|
||||
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
pub struct SequenceSettings {
|
||||
pub width: u32,
|
||||
pub height: u32,
|
||||
pub frame_rate: FrameRate,
|
||||
/// Pixel aspect ratio.
|
||||
pub par: (u32, u32),
|
||||
pub sample_rate: u32,
|
||||
/// Drop-frame timecode; only at rates that have it.
|
||||
pub drop_frame: bool,
|
||||
}
|
||||
|
||||
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 }
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
pub struct Sequence {
|
||||
pub settings: SequenceSettings,
|
||||
/// Bottom track first.
|
||||
pub video_tracks: Vec<Track>,
|
||||
pub audio_tracks: Vec<Track>,
|
||||
pub markers: Vec<Marker>,
|
||||
/// Timecode of the first frame, in frames.
|
||||
pub start_timecode: i64,
|
||||
pub ext: Ext,
|
||||
}
|
||||
|
||||
impl Sequence {
|
||||
pub fn new(settings: SequenceSettings) -> Self {
|
||||
Self { settings, video_tracks: Vec::new(), audio_tracks: Vec::new(), markers: Vec::new(), start_timecode: 0, ext: None }
|
||||
}
|
||||
/// The end of the last clip.
|
||||
pub fn duration(&self) -> Tick {
|
||||
self.all_tracks().map(Track::end).max().unwrap_or(Tick::ZERO)
|
||||
}
|
||||
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 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 find_item(&self, id: ClipId) -> Option<(TrackId, &TrackItem)> {
|
||||
self.all_tracks().find_map(|t| t.item(id).map(|i| (t.id, i)))
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Hash)]
|
||||
pub enum TrackKind {
|
||||
#[default]
|
||||
Video,
|
||||
Audio,
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
|
||||
pub enum AudioChannels {
|
||||
Mono,
|
||||
#[default]
|
||||
Stereo,
|
||||
Surround51,
|
||||
Adaptive,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
pub struct Track {
|
||||
pub id: TrackId,
|
||||
pub kind: TrackKind,
|
||||
pub name: String,
|
||||
/// Sorted by start; items do not overlap.
|
||||
pub items: Vec<TrackItem>,
|
||||
pub transitions: Vec<Transition>,
|
||||
pub locked: bool,
|
||||
pub enabled: bool,
|
||||
pub channels: AudioChannels,
|
||||
pub ext: Ext,
|
||||
}
|
||||
|
||||
impl Track {
|
||||
pub fn new(id: TrackId, kind: TrackKind, name: String) -> Self {
|
||||
Self { id, kind, name, items: Vec::new(), transitions: Vec::new(), locked: false, enabled: true, channels: AudioChannels::Stereo, ext: None }
|
||||
}
|
||||
pub fn end(&self) -> Tick {
|
||||
self.items.iter().map(|i| i.end()).max().unwrap_or(Tick::ZERO)
|
||||
}
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
/// A clip instance on a track.
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
pub struct TrackItem {
|
||||
pub id: ClipId,
|
||||
/// The item it shows.
|
||||
pub item: ItemId,
|
||||
pub name: String,
|
||||
pub label: Label,
|
||||
pub start: Tick,
|
||||
pub duration: Tick,
|
||||
/// Media time of the first frame shown (of the last, when reversed, plus the clip's span).
|
||||
pub source_in: Tick,
|
||||
/// Playback speed, positive; `reverse` gives the direction.
|
||||
pub speed: f64,
|
||||
pub reverse: bool,
|
||||
pub enabled: bool,
|
||||
/// Clips with the same link id are one clip's picture and sound.
|
||||
pub link: Option<u64>,
|
||||
pub group: Option<u64>,
|
||||
pub effects: Vec<EffectInstance>,
|
||||
/// Clip markers, in media time.
|
||||
pub markers: Vec<Marker>,
|
||||
pub gain_db: f64,
|
||||
/// A freeze frame: the media time held for the whole clip.
|
||||
pub frame_hold: Option<Tick>,
|
||||
/// Scale the picture to the sequence frame.
|
||||
pub scale_to_frame: bool,
|
||||
/// Sound: the channels of the media this clip plays (0-based); empty for all of them. One
|
||||
/// channel is a mono clip split off a multichannel file.
|
||||
pub source_channels: Vec<u16>,
|
||||
pub ext: Ext,
|
||||
}
|
||||
|
||||
impl TrackItem {
|
||||
pub fn end(&self) -> Tick {
|
||||
self.start + self.duration
|
||||
}
|
||||
pub fn range(&self) -> TimeRange {
|
||||
TimeRange::new(self.start, self.duration)
|
||||
}
|
||||
/// Media time one past the last frame shown.
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
|
||||
pub enum TransitionAlign {
|
||||
#[default]
|
||||
CenterAtCut,
|
||||
StartAtCut,
|
||||
EndAtCut,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
pub struct Transition {
|
||||
pub id: TransitionId,
|
||||
pub effect: EffectInstance,
|
||||
pub start: Tick,
|
||||
pub duration: Tick,
|
||||
/// The outgoing clip (`None`: a fade in from nothing).
|
||||
pub from: Option<ClipId>,
|
||||
/// The incoming clip (`None`: a fade out).
|
||||
pub to: Option<ClipId>,
|
||||
pub align: TransitionAlign,
|
||||
pub reverse: bool,
|
||||
pub ext: Ext,
|
||||
}
|
||||
|
||||
impl Transition {
|
||||
pub fn end(&self) -> Tick {
|
||||
self.start + self.duration
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
|
||||
pub enum MarkerKind {
|
||||
#[default]
|
||||
Comment,
|
||||
Chapter,
|
||||
Segmentation,
|
||||
WebLink,
|
||||
FlashCue,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
pub struct Marker {
|
||||
pub id: MarkerId,
|
||||
pub start: Tick,
|
||||
pub duration: Tick,
|
||||
pub name: String,
|
||||
pub comment: String,
|
||||
pub kind: MarkerKind,
|
||||
pub color: Label,
|
||||
pub url: String,
|
||||
pub author: String,
|
||||
pub ext: Ext,
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------------------------
|
||||
// Effects
|
||||
// ---------------------------------------------------------------------------------------------
|
||||
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
pub enum ParamValue {
|
||||
Float(f64),
|
||||
/// A point; NaN components mean "automatic" (the centre of the frame or source).
|
||||
Vec2(Vec2),
|
||||
/// RGBA, 0..=1.
|
||||
Color([f32; 4]),
|
||||
Bool(bool),
|
||||
Choice(u32),
|
||||
Text(String),
|
||||
/// A value only the application reads (a curve, a mask path), as it serialises it.
|
||||
Opaque(String),
|
||||
}
|
||||
|
||||
impl ParamValue {
|
||||
pub fn as_f64(&self) -> Option<f64> {
|
||||
match self {
|
||||
ParamValue::Float(v) => Some(*v),
|
||||
ParamValue::Choice(c) => Some(*c as f64),
|
||||
ParamValue::Bool(b) => Some(*b as u8 as f64),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
pub fn as_vec2(&self) -> Option<Vec2> {
|
||||
match self {
|
||||
ParamValue::Vec2(v) => Some(*v),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
pub fn as_color(&self) -> Option<[f32; 4]> {
|
||||
match self {
|
||||
ParamValue::Color(c) => Some(*c),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
pub fn as_bool(&self) -> Option<bool> {
|
||||
match self {
|
||||
ParamValue::Bool(b) => Some(*b),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
|
||||
pub enum Interpolation {
|
||||
#[default]
|
||||
Linear,
|
||||
Hold,
|
||||
Bezier,
|
||||
AutoBezier,
|
||||
ContinuousBezier,
|
||||
EaseIn,
|
||||
EaseOut,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
pub struct Keyframe {
|
||||
/// Media time.
|
||||
pub time: Tick,
|
||||
pub value: ParamValue,
|
||||
/// How the value moves from this keyframe to the next.
|
||||
pub interp: Interpolation,
|
||||
pub out_influence: f64,
|
||||
pub in_influence: f64,
|
||||
}
|
||||
|
||||
impl Keyframe {
|
||||
pub fn new(time: Tick, value: ParamValue) -> Self {
|
||||
Self { time, value, interp: Interpolation::Linear, out_influence: 1.0 / 3.0, in_influence: 1.0 / 3.0 }
|
||||
}
|
||||
}
|
||||
|
||||
/// A parameter: its value, or keyframes sorted by time.
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
pub struct Param {
|
||||
pub value: ParamValue,
|
||||
pub keyframes: Vec<Keyframe>,
|
||||
}
|
||||
|
||||
impl Param {
|
||||
pub fn new(value: ParamValue) -> Self {
|
||||
Self { value, keyframes: Vec::new() }
|
||||
}
|
||||
pub fn is_animated(&self) -> bool {
|
||||
!self.keyframes.is_empty()
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
pub struct EffectInstance {
|
||||
/// The effect's id (see [`fx`] and [`Catalog`]).
|
||||
pub effect: String,
|
||||
pub enabled: bool,
|
||||
pub params: BTreeMap<String, Param>,
|
||||
pub ext: Ext,
|
||||
}
|
||||
|
||||
impl EffectInstance {
|
||||
pub fn param(&self, id: &str) -> Option<&Param> {
|
||||
self.params.get(id)
|
||||
}
|
||||
pub fn param_mut(&mut self, id: &str) -> Option<&mut Param> {
|
||||
self.params.get_mut(id)
|
||||
}
|
||||
pub fn is_animated(&self) -> bool {
|
||||
self.params.values().any(Param::is_animated)
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub enum EffectKind {
|
||||
Video,
|
||||
Audio,
|
||||
VideoTransition,
|
||||
AudioTransition,
|
||||
}
|
||||
|
||||
/// What the formats need to know about an effect.
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
pub struct EffectDef {
|
||||
pub id: String,
|
||||
/// The name a document writes and other applications read back. It must not change when the
|
||||
/// application renames the effect in its interface.
|
||||
pub name: String,
|
||||
pub kind: EffectKind,
|
||||
/// The folder a document files it under (`Dissolve`, `Blur & Sharpen`, …); may be empty.
|
||||
pub category: String,
|
||||
/// Fixed effects every clip has (motion, opacity, volume): never written as a filter of their own.
|
||||
pub intrinsic: bool,
|
||||
/// Parameters, in the effect's order.
|
||||
pub params: Vec<ParamDef>,
|
||||
}
|
||||
|
||||
/// A parameter of an effect: its id, the name a document shows for it, and its default, whose
|
||||
/// variant is the parameter's type.
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
pub struct ParamDef {
|
||||
pub id: String,
|
||||
pub label: String,
|
||||
pub default: ParamValue,
|
||||
}
|
||||
|
||||
impl EffectDef {
|
||||
pub fn param(&self, id: &str) -> Option<&ParamDef> {
|
||||
self.params.iter().find(|p| p.id == id)
|
||||
}
|
||||
/// An instance with every parameter at its default.
|
||||
pub fn instance(&self) -> EffectInstance {
|
||||
EffectInstance {
|
||||
effect: self.id.clone(),
|
||||
enabled: true,
|
||||
params: self.params.iter().map(|p| (p.id.clone(), Param::new(p.default.clone()))).collect(),
|
||||
ext: None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The application's effects and generators, for what a format does not know itself.
|
||||
pub trait Catalog {
|
||||
/// The effect with this id.
|
||||
fn effect(&self, id: &str) -> Option<EffectDef>;
|
||||
/// The effect a document names (its [`EffectDef::name`], or a name the application also answers to).
|
||||
fn effect_by_name(&self, name: &str) -> Option<EffectDef>;
|
||||
/// The fixed effects a new clip on a track of `kind` starts with.
|
||||
fn intrinsic(&self, kind: TrackKind) -> Vec<EffectInstance>;
|
||||
/// A generator a document names, when it is one of the application's own.
|
||||
fn generator_by_name(&self, _name: &str) -> Option<Generator> {
|
||||
None
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user