668 lines
24 KiB
Rust
668 lines
24 KiB
Rust
//! Shared helpers: fragment builder, track placement, media paths/URLs, effect and transition
|
|
//! mapping, frame/tick conversion.
|
|
|
|
use std::collections::HashMap;
|
|
|
|
use filmcraft_color::ColorInfo;
|
|
use filmcraft_media::{AudioStreamInfo, Generator, MediaInfo, MediaKind, VideoStreamInfo};
|
|
use filmcraft_project::{
|
|
BinId, ClipId, EffectInstance, EffectKind, Interpretation, ItemId, ItemKind, Label, MediaClip, MediaRef, Param, Project, Sequence, SequenceSettings, Track,
|
|
TrackId, TrackItem, TrackKind, effect, effect::find_effect_by_name, find_effect,
|
|
};
|
|
use filmcraft_time::{FrameRate, Tick};
|
|
|
|
use crate::{Imported, Report};
|
|
|
|
// ---------------------------------------------------------------------------------------------
|
|
// Paths and URLs
|
|
// ---------------------------------------------------------------------------------------------
|
|
|
|
fn is_windows_abs(p: &str) -> bool {
|
|
let b = p.as_bytes();
|
|
b.len() >= 3 && b[0].is_ascii_alphabetic() && b[1] == b':' && (b[2] == b'/' || b[2] == b'\\')
|
|
}
|
|
|
|
pub(crate) fn is_absolute(p: &str) -> bool {
|
|
p.starts_with('/') || p.starts_with("\\\\") || is_windows_abs(p)
|
|
}
|
|
|
|
fn percent_decode(s: &str) -> String {
|
|
let b = s.as_bytes();
|
|
let mut out = Vec::with_capacity(b.len());
|
|
let mut i = 0;
|
|
while i < b.len() {
|
|
if b[i] == b'%' && i + 2 < b.len() {
|
|
let h = std::str::from_utf8(&b[i + 1..i + 3]).ok().and_then(|h| u8::from_str_radix(h, 16).ok());
|
|
if let Some(v) = h {
|
|
out.push(v);
|
|
i += 3;
|
|
continue;
|
|
}
|
|
}
|
|
out.push(b[i]);
|
|
i += 1;
|
|
}
|
|
String::from_utf8(out).unwrap_or_else(|e| String::from_utf8_lossy(e.as_bytes()).into_owned())
|
|
}
|
|
|
|
fn percent_encode_path(s: &str) -> String {
|
|
let mut out = String::with_capacity(s.len());
|
|
for &c in s.as_bytes() {
|
|
if c.is_ascii_alphanumeric() || b"/-._~!$&'()*+,;=:@".contains(&c) {
|
|
out.push(c as char);
|
|
} else {
|
|
out.push_str(&format!("%{c:02X}"));
|
|
}
|
|
}
|
|
out
|
|
}
|
|
|
|
/// Convert a `file://` URL (`file:///a/b`, `file://localhost/a/b`, `file:///C:/x`) to a path.
|
|
/// Strings that are not file URLs are returned unchanged.
|
|
pub fn file_url_to_path(url: &str) -> String {
|
|
let lower = url.get(..7).map(|s| s.to_ascii_lowercase());
|
|
if lower.as_deref() != Some("file://") {
|
|
return url.to_string();
|
|
}
|
|
let rest = &url[7..];
|
|
let rest = if rest.len() >= 9 && rest[..9].eq_ignore_ascii_case("localhost") { &rest[9..] } else { rest };
|
|
let decoded = percent_decode(rest);
|
|
// file:///C:/x -> C:/x
|
|
if decoded.len() >= 3 && decoded.starts_with('/') && is_windows_abs(&decoded[1..]) {
|
|
return decoded[1..].to_string();
|
|
}
|
|
if !decoded.starts_with('/') {
|
|
// file://server/share -> UNC
|
|
return format!("//{decoded}");
|
|
}
|
|
decoded
|
|
}
|
|
|
|
/// Convert an absolute path to a `file://` URL (`localhost` form for FCP7 XML).
|
|
pub fn path_to_file_url(path: &str, localhost: bool) -> String {
|
|
let p = path.replace('\\', "/");
|
|
let host = if localhost { "localhost" } else { "" };
|
|
if is_windows_abs(&p) {
|
|
format!("file://{host}/{}", percent_encode_path(&p))
|
|
} else if let Some(unc) = p.strip_prefix("//") {
|
|
format!("file://{}", percent_encode_path(unc))
|
|
} else {
|
|
format!("file://{host}{}", percent_encode_path(&p))
|
|
}
|
|
}
|
|
|
|
fn join(base: &str, rel: &str) -> String {
|
|
let sep = if base.contains('\\') && !base.contains('/') { '\\' } else { '/' };
|
|
let mut parts: Vec<&str> = base.split(['/', '\\']).collect();
|
|
if parts.last() == Some(&"") && parts.len() > 1 {
|
|
parts.pop();
|
|
}
|
|
for seg in rel.split(['/', '\\']) {
|
|
match seg {
|
|
"" | "." => {}
|
|
".." => {
|
|
if parts.len() > 1 {
|
|
parts.pop();
|
|
}
|
|
}
|
|
s => parts.push(s),
|
|
}
|
|
}
|
|
parts.join(&sep.to_string())
|
|
}
|
|
|
|
/// Resolve a media reference from a document: file URLs become paths, relative paths are joined to
|
|
/// `base_dir` (when given).
|
|
pub fn resolve_path(reference: &str, base_dir: Option<&str>) -> String {
|
|
let p = file_url_to_path(reference.trim());
|
|
if is_absolute(&p) {
|
|
return p;
|
|
}
|
|
match base_dir {
|
|
Some(b) if !b.is_empty() => join(b, &p),
|
|
_ => p,
|
|
}
|
|
}
|
|
|
|
/// `path` relative to directory `base`, if `path` lies under it (or shares a prefix).
|
|
pub(crate) fn relative_path(path: &str, base: &str) -> Option<String> {
|
|
if !is_absolute(path) || !is_absolute(base) {
|
|
return None;
|
|
}
|
|
let a: Vec<&str> = path.split(['/', '\\']).filter(|s| !s.is_empty()).collect();
|
|
let b: Vec<&str> = base.split(['/', '\\']).filter(|s| !s.is_empty()).collect();
|
|
let common = a.iter().zip(&b).take_while(|(x, y)| x == y).count();
|
|
if common == 0 {
|
|
return None;
|
|
}
|
|
let mut out: Vec<&str> = std::iter::repeat_n("..", b.len() - common).collect();
|
|
out.extend(&a[common..]);
|
|
Some(out.join("/"))
|
|
}
|
|
|
|
/// Final path component.
|
|
pub(crate) fn file_name(path: &str) -> &str {
|
|
path.rsplit(['/', '\\']).next().unwrap_or(path)
|
|
}
|
|
|
|
/// File name without extension.
|
|
pub(crate) fn file_stem(path: &str) -> &str {
|
|
let n = file_name(path);
|
|
match n.rfind('.') {
|
|
Some(i) if i > 0 => &n[..i],
|
|
_ => n,
|
|
}
|
|
}
|
|
|
|
pub(crate) fn extension(path: &str) -> String {
|
|
let n = file_name(path);
|
|
n.rfind('.').map(|i| n[i + 1..].to_ascii_lowercase()).unwrap_or_default()
|
|
}
|
|
|
|
/// Media kind guess from a file extension.
|
|
pub(crate) 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,
|
|
}
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------------------------
|
|
// Frames and rates
|
|
// ---------------------------------------------------------------------------------------------
|
|
|
|
/// Frame count nearest to `t` at `rate`.
|
|
pub(crate) fn frames_round(rate: FrameRate, t: Tick) -> i64 {
|
|
let f = rate.frame_at(t);
|
|
if rate.tick_of(f + 1) - t < t - rate.tick_of(f) { f + 1 } else { f }
|
|
}
|
|
|
|
/// Whether `t` is exactly on a frame boundary of `rate`.
|
|
pub(crate) fn on_frame(rate: FrameRate, t: Tick) -> bool {
|
|
rate.tick_of(rate.frame_at(t)) == t
|
|
}
|
|
|
|
/// FCP7-style `(timebase, ntsc)` for a rate.
|
|
pub(crate) fn timebase(rate: FrameRate) -> (i64, bool) {
|
|
(rate.timecode_base(), rate.is_ntsc())
|
|
}
|
|
|
|
/// Rate from FCP7-style `(timebase, ntsc)`.
|
|
pub(crate) fn rate_from_timebase(timebase: i64, ntsc: bool) -> FrameRate {
|
|
let tb = timebase.max(1);
|
|
if ntsc { FrameRate::new(tb * 1000, 1001) } else { FrameRate::new(tb, 1) }
|
|
}
|
|
|
|
/// Snap a floating rate (OTIO) to an exact rate.
|
|
pub(crate) fn rate_from_f64(r: f64) -> FrameRate {
|
|
if r <= 0.0 || !r.is_finite() {
|
|
return FrameRate::FPS_24;
|
|
}
|
|
for c in FrameRate::COMMON {
|
|
if (c.as_f64() - r).abs() < 1e-3 {
|
|
return c;
|
|
}
|
|
}
|
|
let n = (r * 1001.0).round();
|
|
if ((n / 1001.0) - r).abs() < 1e-9 && (n as i64) % 1000 == 0 {
|
|
return FrameRate::new(n as i64, 1001);
|
|
}
|
|
if (r - r.round()).abs() < 1e-9 {
|
|
return FrameRate::new(r.round() as i64, 1);
|
|
}
|
|
FrameRate::new((r * 1_000_000.0).round() as i64, 1_000_000)
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------------------------
|
|
// Media descriptions
|
|
// ---------------------------------------------------------------------------------------------
|
|
|
|
/// What a document says about a media file.
|
|
#[derive(Clone, Debug, Default)]
|
|
pub(crate) 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(crate) 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),
|
|
codec: String::new(),
|
|
pixel_format: String::new(),
|
|
color: ColorInfo::REC709,
|
|
has_alpha: false,
|
|
bitrate: None,
|
|
}),
|
|
audio: spec.audio.map(|(sr, ch)| AudioStreamInfo { sample_rate: sr, channels: ch, codec: String::new(), bits_per_sample: None }),
|
|
container: String::new(),
|
|
start_timecode: spec.start_tc,
|
|
file_size: None,
|
|
}
|
|
}
|
|
|
|
pub(crate) fn media_clip(media: MediaRef, info: MediaInfo) -> MediaClip {
|
|
MediaClip { media, info, interpret: Interpretation::default(), mark_in: None, mark_out: None, markers: Vec::new(), offline: false, proxy: None }
|
|
}
|
|
|
|
/// The file path behind an item (following subclips), if it is file media.
|
|
pub(crate) fn item_path(p: &Project, id: ItemId) -> Option<&str> {
|
|
match &p.item(id)?.kind {
|
|
ItemKind::Media(m) => match &m.media {
|
|
MediaRef::File { path } => Some(path),
|
|
MediaRef::Generator(_) => None,
|
|
},
|
|
ItemKind::Subclip { parent, .. } => item_path(p, *parent),
|
|
_ => None,
|
|
}
|
|
}
|
|
|
|
/// The media clip behind an item (following subclips).
|
|
pub(crate) fn item_media(p: &Project, id: ItemId) -> Option<&MediaClip> {
|
|
match &p.item(id)?.kind {
|
|
ItemKind::Media(m) => Some(m),
|
|
ItemKind::Subclip { parent, .. } => item_media(p, *parent),
|
|
_ => None,
|
|
}
|
|
}
|
|
|
|
/// The underlying item for exports (subclip → parent media).
|
|
pub(crate) fn base_item(p: &Project, id: ItemId) -> ItemId {
|
|
match p.item(id).map(|i| &i.kind) {
|
|
Some(ItemKind::Subclip { parent, .. }) => base_item(p, *parent),
|
|
_ => id,
|
|
}
|
|
}
|
|
|
|
pub(crate) fn generator_of(p: &Project, id: ItemId) -> Option<&Generator> {
|
|
match &item_media(p, id)?.media {
|
|
MediaRef::Generator(g) => Some(g),
|
|
MediaRef::File { .. } => None,
|
|
}
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------------------------
|
|
// Fragment builder
|
|
// ---------------------------------------------------------------------------------------------
|
|
|
|
pub(crate) struct Builder {
|
|
pub p: Project,
|
|
media: HashMap<String, ItemId>,
|
|
pub top: Vec<ItemId>,
|
|
next_link: u64,
|
|
}
|
|
|
|
impl Builder {
|
|
pub fn new(name: &str) -> Self {
|
|
Self { p: Project::new(name), 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(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(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)
|
|
}
|
|
|
|
pub fn ensure_tracks(&mut self, seq: &mut Sequence, kind: TrackKind, n: usize) {
|
|
while seq.tracks(kind).len() < n {
|
|
let i = seq.tracks(kind).len();
|
|
let t = self.track(kind, i);
|
|
seq.tracks_mut(kind).push(t);
|
|
}
|
|
}
|
|
|
|
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: match kind {
|
|
TrackKind::Video => effect::intrinsic_video(),
|
|
TrackKind::Audio => effect::intrinsic_audio(),
|
|
},
|
|
markers: Vec::new(),
|
|
gain_db: 0.0,
|
|
frame_hold: None,
|
|
scale_to_frame: false,
|
|
essential: 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) -> usize {
|
|
let mut i = preferred;
|
|
loop {
|
|
self.ensure_tracks(seq, kind, i + 1);
|
|
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 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(crate) fn empty_sequence(settings: SequenceSettings) -> Sequence {
|
|
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: Default::default(),
|
|
submix_tracks: Vec::new(),
|
|
caption_tracks: Vec::new(),
|
|
}
|
|
}
|
|
|
|
pub(crate) 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(),
|
|
preset: format!("Custom {width}x{height} {}", rate.label()),
|
|
..SequenceSettings::default()
|
|
}
|
|
}
|
|
|
|
/// Source time advanced by a timeline duration at `speed` (exact for integral speeds).
|
|
pub(crate) fn scaled(d: Tick, speed: f64) -> Tick {
|
|
let s = speed.abs();
|
|
if s == s.round() && s < 1e6 { Tick(d.0 * s as i64) } else { Tick((d.0 as f64 * s).round() as i64) }
|
|
}
|
|
|
|
/// Timeline duration of a source duration at `speed`.
|
|
pub(crate) 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) }
|
|
}
|
|
|
|
/// Overwrite `item` onto `track` (EDL record semantics): whatever was under it is trimmed/split.
|
|
pub(crate) fn overwrite(track: &mut Track, item: TrackItem, alloc: &mut dyn FnMut() -> u64) {
|
|
let (s, e) = (item.start, item.end());
|
|
let mut out = Vec::with_capacity(track.items.len() + 2);
|
|
for o in track.items.drain(..) {
|
|
if o.end() <= s || o.start >= e {
|
|
out.push(o);
|
|
continue;
|
|
}
|
|
if o.start < s {
|
|
let mut l = o.clone();
|
|
l.duration = s - o.start;
|
|
out.push(l);
|
|
}
|
|
if o.end() > e {
|
|
let mut r = o.clone();
|
|
r.id = ClipId(alloc());
|
|
r.source_in = o.source_in + scaled(e - o.start, o.speed);
|
|
r.start = e;
|
|
r.duration = o.end() - e;
|
|
out.push(r);
|
|
}
|
|
}
|
|
out.push(item);
|
|
track.items = out;
|
|
track.sort();
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------------------------
|
|
// Effects
|
|
// ---------------------------------------------------------------------------------------------
|
|
|
|
pub(crate) 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(crate) fn set_param(ti: &mut TrackItem, effect: &str, param: &str, value: Param) {
|
|
if ti.effect(effect).is_none()
|
|
&& let Some(d) = find_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(crate) fn param_modified(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 = find_effect(effect).and_then(|d| d.param(param_id)).map(|d| d.default.clone());
|
|
match (&p.value, def) {
|
|
(filmcraft_project::ParamValue::Vec2(v), Some(filmcraft_project::ParamValue::Vec2(_))) => !(v.x.is_nan() && v.y.is_nan()),
|
|
(v, Some(d)) => *v != d,
|
|
_ => false,
|
|
}
|
|
}
|
|
|
|
/// Non-intrinsic effects on an item.
|
|
pub(crate) fn standard_effects(ti: &TrackItem) -> impl Iterator<Item = &EffectInstance> {
|
|
ti.effects.iter().filter(|e| !e.def().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(crate) fn transition_effect(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) = find_effect(id)
|
|
{
|
|
return d.instance();
|
|
}
|
|
let want = if audio { EffectKind::AudioTransition } else { EffectKind::VideoTransition };
|
|
if let Some(d) = find_effect_by_name(name.trim()).filter(|d| d.kind == want) {
|
|
return d.instance();
|
|
}
|
|
let fallback = if audio { "constant_power" } else { "cross_dissolve" };
|
|
report.warn(format!("transition \"{name}\" is not supported; imported as {}", find_effect(fallback).map(|d| d.name).unwrap_or(fallback)));
|
|
find_effect(fallback).map(|d| d.instance()).unwrap_or_else(|| EffectInstance {
|
|
effect: fallback.into(),
|
|
enabled: true,
|
|
params: Default::default(),
|
|
masks: vec![],
|
|
post_fader: false,
|
|
essential: false,
|
|
})
|
|
}
|
|
|
|
/// Display name of a transition (for documents).
|
|
pub(crate) fn transition_name(e: &EffectInstance) -> String {
|
|
e.def().map(|d| d.name.to_string()).unwrap_or_else(|| e.effect.clone())
|
|
}
|
|
|
|
/// Label from a document label name (Premiere label names; FCP7 names like "Good Take" ignored).
|
|
pub(crate) fn label_from(s: &str) -> Option<Label> {
|
|
Label::from_name(s.trim())
|
|
}
|
|
|
|
/// dB ↔ linear gain.
|
|
pub(crate) fn db_to_gain(db: f64) -> f64 {
|
|
if db <= -287.0 { 0.0 } else { 10f64.powf(db / 20.0) }
|
|
}
|
|
pub(crate) fn gain_to_db(g: f64) -> f64 {
|
|
if g <= 1e-15 { -287.5 } else { 20.0 * g.log10() }
|
|
}
|
|
|
|
/// Sanitise a name for fixed-width EDL fields.
|
|
pub(crate) fn sanitize_reel(s: &str, max: usize) -> String {
|
|
let r: String = s.chars().map(|c| if c.is_ascii_alphanumeric() || c == '_' || c == '-' { c.to_ascii_uppercase() } else { '_' }).take(max).collect();
|
|
if r.is_empty() { "AX".into() } else { r }
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
|
|
#[test]
|
|
fn urls() {
|
|
assert_eq!(file_url_to_path("file://localhost/Users/me/My%20Clip.mov"), "/Users/me/My Clip.mov");
|
|
assert_eq!(file_url_to_path("file:///C:/Media/a.mxf"), "C:/Media/a.mxf");
|
|
assert_eq!(file_url_to_path("/plain/path.mov"), "/plain/path.mov");
|
|
assert_eq!(path_to_file_url("/Users/me/My Clip.mov", true), "file://localhost/Users/me/My%20Clip.mov");
|
|
assert_eq!(path_to_file_url("C:\\Media\\a b.mxf", false), "file:///C:/Media/a%20b.mxf");
|
|
for p in ["/a/b c/ü.mov", "C:/x/y.mp4", "/r/%/#?.mov"] {
|
|
assert_eq!(file_url_to_path(&path_to_file_url(p, true)), p);
|
|
assert_eq!(file_url_to_path(&path_to_file_url(p, false)), p);
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn paths() {
|
|
assert_eq!(resolve_path("media/a.mov", Some("/proj")), "/proj/media/a.mov");
|
|
assert_eq!(resolve_path("../a.mov", Some("/proj/x/")), "/proj/a.mov");
|
|
assert_eq!(resolve_path("/abs/a.mov", Some("/proj")), "/abs/a.mov");
|
|
assert_eq!(relative_path("/proj/media/a.mov", "/proj").as_deref(), Some("media/a.mov"));
|
|
assert_eq!(relative_path("/proj/a.mov", "/proj/edl").as_deref(), Some("../a.mov"));
|
|
assert_eq!(file_stem("/x/y/Clip 01.final.mov"), "Clip 01.final");
|
|
}
|
|
|
|
#[test]
|
|
fn rates() {
|
|
assert_eq!(rate_from_timebase(24, true), FrameRate::FPS_23_976);
|
|
assert_eq!(rate_from_timebase(30, true), FrameRate::FPS_29_97);
|
|
assert_eq!(rate_from_timebase(60, true), FrameRate::FPS_59_94);
|
|
assert_eq!(rate_from_timebase(25, false), FrameRate::FPS_25);
|
|
assert_eq!(rate_from_f64(24000.0 / 1001.0), FrameRate::FPS_23_976);
|
|
assert_eq!(rate_from_f64(23.976), FrameRate::FPS_23_976);
|
|
assert_eq!(rate_from_f64(48000.0), FrameRate::new(48000, 1));
|
|
assert_eq!(frames_round(FrameRate::FPS_24, FrameRate::FPS_24.tick_of(5) + Tick(10)), 5);
|
|
}
|
|
}
|