mirror of
https://github.com/storytold/craft-libs.git
synced 2026-11-12 17:03:46 +00:00
* craft-timeline: exact media time, reports, media paths, ALE and CMX 3600 EDL New crate for editorial timeline interchange (#782), moved from FilmCraft at 4bec22bd: - time: filmcraft-time whole (Tick, FrameRate, TimeRange, SMPTE timecode, feet+frames, typed-timecode parsing) with its tests. - report: Report / Level / ReportEntry. - path: file URL and path conversion, relative references joined to a folder. - rate: timebase+ntsc and floating rates to exact rates, MAX_RATE. - ale: AleDoc, parse, write, standard heading and columns. - edl: the parser (events, lines, timecode fields, M2, AUD, comment extensions), the frame-rate guess, and the line layout of the writer, on frame counts. - Format, detect_text, decode_text. Each format module gives the document as the format writes it. Mapping onto an application's timeline stays in the application. * craft-timeline: strip a UTF-8 byte order mark when parsing ALE files An ALE saved with a BOM was refused as "not an ALE file": the mark made the first line something other than "Heading". Ported from storytold/filmcraft#4531. --------- Co-authored-by: NetterSilver <133535268+Silvertree2010@users.noreply.github.com>
151 lines
6.1 KiB
Rust
151 lines
6.1 KiB
Rust
#![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)]
|
||
//! Editorial timeline interchange: the parts every reader and writer of an edit shares.
|
||
//!
|
||
//! - [`time`]: exact media time. A [`time::Tick`] divides evenly into every broadcast frame and
|
||
//! common audio sample duration, so frame counts, SMPTE timecode and rational seconds convert
|
||
//! without drift. [`rate`] has the frame-rate conventions documents use (`timebase` + `ntsc`,
|
||
//! floating rates).
|
||
//! - [`report`]: what an import or export could not represent exactly.
|
||
//! - [`path`]: media references (`file://` URLs, absolute and relative paths), lexically.
|
||
//! - [`ale`]: Avid Log Exchange clip logs.
|
||
//! - [`edl`]: CMX 3600 edit decision lists.
|
||
//! - [`Format`], [`detect_text`], [`decode_text`]: which interchange format a document is.
|
||
//!
|
||
//! No file I/O: documents are text or bytes, and media references are strings. Each format module
|
||
//! gives the document as the format writes it; mapping it onto an application's own timeline is
|
||
//! the caller's.
|
||
|
||
pub mod ale;
|
||
pub mod edl;
|
||
pub mod path;
|
||
pub mod rate;
|
||
pub mod report;
|
||
pub mod time;
|
||
|
||
pub use report::{Level, Report, ReportEntry};
|
||
pub use time::{FrameRate, Tick};
|
||
|
||
/// A timeline interchange format.
|
||
#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
|
||
#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
|
||
pub enum Format {
|
||
/// CMX 3600 edit decision list.
|
||
Edl,
|
||
/// Final Cut Pro 7 XML interchange (`xmeml`), as read/written by Premiere Pro.
|
||
Fcp7Xml,
|
||
/// Final Cut Pro X XML (1.9–1.11).
|
||
Fcpxml,
|
||
/// OpenTimelineIO JSON.
|
||
Otio,
|
||
/// Advanced Authoring Format (AAF Edit Protocol, structured storage).
|
||
Aaf,
|
||
/// OMF Interchange 2.0 (Bento container).
|
||
Omf,
|
||
}
|
||
|
||
impl Format {
|
||
pub const ALL: [Format; 6] = [Format::Edl, Format::Fcp7Xml, Format::Fcpxml, Format::Otio, Format::Aaf, Format::Omf];
|
||
|
||
pub fn name(self) -> &'static str {
|
||
match self {
|
||
Format::Edl => "CMX 3600 EDL",
|
||
Format::Fcp7Xml => "Final Cut Pro XML",
|
||
Format::Fcpxml => "FCPXML",
|
||
Format::Otio => "OpenTimelineIO",
|
||
Format::Aaf => "AAF",
|
||
Format::Omf => "OMF",
|
||
}
|
||
}
|
||
|
||
/// Default file extension (without the dot).
|
||
pub fn extension(self) -> &'static str {
|
||
match self {
|
||
Format::Edl => "edl",
|
||
Format::Fcp7Xml => "xml",
|
||
Format::Fcpxml => "fcpxml",
|
||
Format::Otio => "otio",
|
||
Format::Aaf => "aaf",
|
||
Format::Omf => "omf",
|
||
}
|
||
}
|
||
|
||
/// Format for a file extension (case-insensitive, with or without the dot). `.xml` is ambiguous
|
||
/// between xmeml and FCPXML; use [`detect_text`] when the bytes are available.
|
||
pub fn from_extension(ext: &str) -> Option<Format> {
|
||
match ext.trim_start_matches('.').to_ascii_lowercase().as_str() {
|
||
"edl" => Some(Format::Edl),
|
||
"xml" => Some(Format::Fcp7Xml),
|
||
"fcpxml" | "fcpxmld" => Some(Format::Fcpxml),
|
||
"otio" => Some(Format::Otio),
|
||
"aaf" => Some(Format::Aaf),
|
||
"omf" | "omfi" => Some(Format::Omf),
|
||
_ => None,
|
||
}
|
||
}
|
||
}
|
||
|
||
/// 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> {
|
||
let head = String::from_utf8_lossy(bytes.get(..bytes.len().min(4096)).unwrap_or_default());
|
||
let head = head.trim_start_matches('\u{feff}').trim_start();
|
||
if head.starts_with('<') {
|
||
if head.contains("<xmeml") {
|
||
return Some(Format::Fcp7Xml);
|
||
}
|
||
if head.contains("<fcpxml") {
|
||
return Some(Format::Fcpxml);
|
||
}
|
||
return extension.and_then(Format::from_extension).filter(|f| matches!(f, Format::Fcp7Xml | Format::Fcpxml));
|
||
}
|
||
if head.starts_with('{') {
|
||
if head.contains("\"OTIO_SCHEMA\"") {
|
||
return Some(Format::Otio);
|
||
}
|
||
return extension.and_then(Format::from_extension).filter(|f| *f == Format::Otio);
|
||
}
|
||
if edl::looks_like_edl(head) {
|
||
return Some(Format::Edl);
|
||
}
|
||
extension.and_then(Format::from_extension).filter(|f| *f == Format::Edl)
|
||
}
|
||
|
||
/// The text of a document: UTF-8 (with or without a byte order mark), else Latin-1 (old EDLs).
|
||
pub fn decode_text(bytes: &[u8]) -> String {
|
||
let b = bytes.strip_prefix(&[0xef, 0xbb, 0xbf]).unwrap_or(bytes);
|
||
match std::str::from_utf8(b) {
|
||
Ok(s) => s.to_string(),
|
||
Err(_) => b.iter().map(|&c| c as char).collect(),
|
||
}
|
||
}
|
||
|
||
#[cfg(test)]
|
||
mod tests {
|
||
use super::*;
|
||
|
||
#[test]
|
||
fn text_formats_are_detected_from_bytes_then_extension() {
|
||
assert_eq!(detect_text(b"\xef\xbb\xbf<?xml version=\"1.0\"?>\n<!DOCTYPE xmeml>\n<xmeml version=\"4\">", None), Some(Format::Fcp7Xml));
|
||
assert_eq!(detect_text(b"<?xml version=\"1.0\"?><fcpxml version=\"1.10\">", Some("xml")), Some(Format::Fcpxml));
|
||
assert_eq!(detect_text(b"<?xml version=\"1.0\"?><other/>", Some("xml")), Some(Format::Fcp7Xml));
|
||
assert_eq!(detect_text(b"<?xml version=\"1.0\"?><other/>", Some("edl")), None);
|
||
assert_eq!(detect_text(b"{ \"OTIO_SCHEMA\": \"Timeline.1\" }", None), Some(Format::Otio));
|
||
assert_eq!(detect_text(b"{}", Some(".OTIO")), Some(Format::Otio));
|
||
assert_eq!(detect_text(b"{}", None), None);
|
||
assert_eq!(detect_text(b"TITLE: x\nFCM: NON-DROP FRAME\n", None), Some(Format::Edl));
|
||
assert_eq!(detect_text(b"hello", Some("edl")), Some(Format::Edl));
|
||
assert_eq!(detect_text(b"hello", Some("aaf")), None);
|
||
assert_eq!(detect_text(b"", None), None);
|
||
for f in Format::ALL {
|
||
assert_eq!(Format::from_extension(f.extension()), Some(f), "{}", f.name());
|
||
}
|
||
assert_eq!(Format::from_extension("OMFI"), Some(Format::Omf));
|
||
}
|
||
|
||
#[test]
|
||
fn text_is_utf8_or_latin1() {
|
||
assert_eq!(decode_text(b"\xef\xbb\xbfTITLE: caf\xc3\xa9"), "TITLE: café");
|
||
assert_eq!(decode_text(b"TITLE: caf\xe9"), "TITLE: café");
|
||
}
|
||
}
|