mirror of
https://github.com/storytold/craft-libs.git
synced 2026-11-14 06:22:24 +00:00
craft-timeline: Final Cut Pro 7 XML (xmeml) on the neutral model (#978)
* craft-timeline: a neutral timeline model and the import builder 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). * craft-timeline: Final Cut Pro 7 XML (xmeml) on the neutral model fcp7: FilmCraft's xmeml reader and writer (filmcraft-interchange fcp7.rs at 4bec22bd) moved onto craft_timeline::model, behind feature xml. - XML is read with craft-xml, strictly: at most 256 levels of nesting (the limit EffectCraft checked with its own depth scan before handing documents over), 2 000 000 nodes, 256 MiB; a document type declaration with an internal subset and entities XML does not define are refused. It is written with craft-xml's Writer. - Effects other than motion, opacity, time remap, audio levels and pan are looked up through the caller's Catalog, by id and by name, and written with the catalog's stable name and category. - An application generator round-trips as <namespace>:<payload> in effectid (FilmCraft wrote filmcraft:<json>); transparent video, counting leader and bars and tone are written by name, which the reader already took. - Finding the sequence a clip item is in is one walk of the document instead of an ancestors() lookup per clip item. tests/it: FilmCraft's FCP7 integration tests (the hand-written Premiere-style document, the rich round trip, the 48-case property round trip, split mono and stereo channels, sub-frame spans, absurd timebases and start timecodes, the link-group bound) on the model, with a test catalog for the four application effects they use.
This commit is contained in:
1 parent
59fc18b262
commit
384da3e951
9 files changed
+2857
-1
No files matched your search
Generated
+1
@@ -1763,6 +1763,7 @@ dependencies = [
|
||||
name = "craft-timeline"
|
||||
version = "0.1.0"
|
||||
dependencies = [
|
||||
"craft-xml",
|
||||
"proptest",
|
||||
"serde",
|
||||
]
|
||||
|
||||
@@ -13,9 +13,16 @@ workspace = true
|
||||
[features]
|
||||
# Serialize and Deserialize for the time, report and document types.
|
||||
serde = ["dep:serde"]
|
||||
# Final Cut Pro 7 XML (`fcp7`), read with craft-xml's bounded parser.
|
||||
xml = ["dep:craft-xml"]
|
||||
|
||||
[dependencies]
|
||||
serde = { workspace = true, optional = true }
|
||||
craft-xml = { workspace = true, optional = true }
|
||||
|
||||
[dev-dependencies]
|
||||
proptest = { workspace = true }
|
||||
|
||||
[[test]]
|
||||
name = "it"
|
||||
required-features = ["xml"]
|
||||
@@ -14,6 +14,7 @@ text formats that are documents of their own.
|
||||
| `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. |
|
||||
| `fcp7` (feature `xml`) | Final Cut Pro 7 XML (`xmeml` 4 and 5, the dialect Premiere Pro reads and writes) to and from `model`. Read with craft-xml, strictly and bounded. |
|
||||
| crate root | `Format` (EDL, xmeml, FCPXML, OTIO, AAF, OMF), `detect_text`, `decode_text`. |
|
||||
|
||||
No file I/O. EDL and ALE give the document as the format writes it. The formats that describe a
|
||||
@@ -21,7 +22,7 @@ whole edit read into and write from `model`; mapping `model` onto an application
|
||||
stays in the application.
|
||||
AAF and OMF are in `craft-aaf`.
|
||||
|
||||
Feature `serde`: `Serialize` / `Deserialize` for the time, report and `Format` types.
|
||||
Feature `xml`: the XML formats, on craft-xml. Feature `serde`: `Serialize` / `Deserialize` for the time, report and `Format` types.
|
||||
|
||||
## Specifications
|
||||
|
||||
|
||||
File diff suppressed because it is too large.
Load diff
@@ -13,6 +13,7 @@
|
||||
//! builds a [`model::Project`] while reading.
|
||||
//! - [`ale`]: Avid Log Exchange clip logs.
|
||||
//! - [`edl`]: CMX 3600 edit decision lists.
|
||||
//! - [`fcp7`] (feature `xml`): Final Cut Pro 7 XML (`xmeml`).
|
||||
//! - [`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
|
||||
@@ -22,12 +23,16 @@
|
||||
pub mod ale;
|
||||
pub mod build;
|
||||
pub mod edl;
|
||||
#[cfg(feature = "xml")]
|
||||
pub mod fcp7;
|
||||
pub mod fx;
|
||||
pub mod model;
|
||||
pub mod path;
|
||||
pub mod rate;
|
||||
pub mod report;
|
||||
pub mod time;
|
||||
#[cfg(feature = "xml")]
|
||||
mod xml;
|
||||
|
||||
pub use build::Imported;
|
||||
pub use report::{Level, Report, ReportEntry};
|
||||
@@ -107,6 +112,13 @@ pub enum Error {
|
||||
Other(String),
|
||||
}
|
||||
|
||||
impl Error {
|
||||
#[cfg_attr(not(feature = "xml"), allow(dead_code))]
|
||||
pub(crate) fn parse(format: Format, message: impl Into<String>) -> Error {
|
||||
Error::Parse { format: format.name(), message: message.into() }
|
||||
}
|
||||
}
|
||||
|
||||
impl std::fmt::Display for Error {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
match self {
|
||||
@@ -145,6 +157,8 @@ pub struct ExportOptions<'a> {
|
||||
pub catalog: &'a dyn model::Catalog,
|
||||
/// See [`ImportOptions::namespace`].
|
||||
pub namespace: &'a str,
|
||||
/// `xmeml` version attribute (4 or 5).
|
||||
pub xmeml_version: u32,
|
||||
}
|
||||
|
||||
/// Sniff which text format (EDL, xmeml, FCPXML, OTIO) a document is from its bytes, using the file
|
||||
|
||||
@@ -0,0 +1,199 @@
|
||||
//! XML for the xmeml and FCPXML modules: bounded reading and pretty writing through craft-xml,
|
||||
//! with the small node view their readers are written against.
|
||||
|
||||
use craft_xml::{Doctype, El, Limits, Options, UnknownEntity};
|
||||
|
||||
/// The reader's limits: craft-xml's defaults, which stop a document at 256 levels of nesting
|
||||
/// (real timelines nest a few levels per nested sequence), 2 000 000 nodes and 256 MiB.
|
||||
pub const LIMITS: Limits = Limits::DEFAULT;
|
||||
|
||||
/// Read a document strictly. A plain `<!DOCTYPE xmeml>` is skipped; a declaration with an
|
||||
/// internal subset is refused, and so is a reference to an entity XML does not define, so
|
||||
/// nothing in a document can expand beyond its own size.
|
||||
pub(crate) fn parse(text: &str) -> Result<El, String> {
|
||||
let options =
|
||||
Options { doctype: Doctype::RejectInternalSubset, unknown_entity: UnknownEntity::Error, ..Options::default() }.strict().with_limits(LIMITS);
|
||||
options.parse(text.as_bytes()).map_err(|e| e.to_string())
|
||||
}
|
||||
|
||||
/// An element of a parsed document.
|
||||
#[derive(Clone, Copy)]
|
||||
pub(crate) struct Node<'a>(pub &'a El);
|
||||
|
||||
impl<'a> Node<'a> {
|
||||
pub fn name(self) -> &'a str {
|
||||
self.0.local()
|
||||
}
|
||||
pub fn has_tag_name(self, name: &str) -> bool {
|
||||
self.0.local() == name
|
||||
}
|
||||
pub fn attribute(self, name: &str) -> Option<&'a str> {
|
||||
self.0.attr(name)
|
||||
}
|
||||
/// Child elements.
|
||||
pub fn children(self) -> impl Iterator<Item = Node<'a>> {
|
||||
self.0.elements().map(Node)
|
||||
}
|
||||
/// This element and every element below it, in document order.
|
||||
pub fn descendants(self) -> impl Iterator<Item = Node<'a>> {
|
||||
std::iter::once(self).chain(self.0.descendants().map(Node))
|
||||
}
|
||||
/// The text this element starts with, if it starts with text.
|
||||
pub fn text(self) -> Option<&'a str> {
|
||||
match self.0.children.first() {
|
||||
Some(craft_xml::Node::Text(t) | craft_xml::Node::CData(t)) => Some(t.as_str()),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
/// A number that is this element's alone while the document is alive.
|
||||
pub fn id(self) -> usize {
|
||||
std::ptr::from_ref(self.0) as usize
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn child<'a>(n: Node<'a>, name: &str) -> Option<Node<'a>> {
|
||||
n.children().find(|c| c.name() == name)
|
||||
}
|
||||
|
||||
pub(crate) fn children<'a>(n: Node<'a>, name: &'static str) -> impl Iterator<Item = Node<'a>> {
|
||||
n.children().filter(move |c| c.name() == name)
|
||||
}
|
||||
|
||||
pub(crate) fn elements<'a>(n: Node<'a>) -> impl Iterator<Item = Node<'a>> {
|
||||
n.children()
|
||||
}
|
||||
|
||||
/// Trimmed text of a node's direct text content.
|
||||
pub(crate) fn text(n: Node<'_>) -> &str {
|
||||
n.text().map(str::trim).unwrap_or("")
|
||||
}
|
||||
|
||||
/// Text of the element at `path` below `n`.
|
||||
pub(crate) fn path_text<'a>(n: Node<'a>, path: &[&str]) -> Option<&'a str> {
|
||||
let mut cur = n;
|
||||
for p in path {
|
||||
cur = child(cur, p)?;
|
||||
}
|
||||
Some(text(cur))
|
||||
}
|
||||
|
||||
pub(crate) fn child_text<'a>(n: Node<'a>, name: &str) -> Option<&'a str> {
|
||||
child(n, name).map(text)
|
||||
}
|
||||
|
||||
pub(crate) fn child_i64(n: Node<'_>, name: &str) -> Option<i64> {
|
||||
child_text(n, name).and_then(|t| t.parse::<i64>().ok().or_else(|| t.parse::<f64>().ok().map(|f| f.round() as i64)))
|
||||
}
|
||||
|
||||
pub(crate) fn child_f64(n: Node<'_>, name: &str) -> Option<f64> {
|
||||
child_text(n, name).and_then(|t| t.parse::<f64>().ok())
|
||||
}
|
||||
|
||||
pub(crate) fn child_bool(n: Node<'_>, name: &str) -> Option<bool> {
|
||||
child_text(n, name).map(|t| t.eq_ignore_ascii_case("true") || t == "1")
|
||||
}
|
||||
|
||||
pub(crate) fn bool_str(b: bool) -> &'static str {
|
||||
if b { "TRUE" } else { "FALSE" }
|
||||
}
|
||||
|
||||
/// Format an f64 compactly (no trailing zeros, no exponent for normal ranges).
|
||||
pub(crate) fn num(v: f64) -> String {
|
||||
if v == v.round() && v.abs() < 1e15 {
|
||||
return format!("{}", v as i64);
|
||||
}
|
||||
if !v.is_finite() {
|
||||
return "0".into();
|
||||
}
|
||||
// Shortest representation that parses back to the same f64.
|
||||
format!("{v}")
|
||||
}
|
||||
|
||||
/// Pretty-printing XML writer (two-space indent, one element per line).
|
||||
pub(crate) struct XmlWriter(craft_xml::Writer);
|
||||
|
||||
impl XmlWriter {
|
||||
pub fn new(doctype: Option<&str>) -> Self {
|
||||
let mut w = craft_xml::Writer::pretty(" ");
|
||||
w.raw("<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n");
|
||||
if let Some(d) = doctype {
|
||||
w.doctype(d);
|
||||
}
|
||||
Self(w)
|
||||
}
|
||||
pub fn open(&mut self, name: &str, attrs: &[(&str, &str)]) {
|
||||
self.0.open(name, attrs);
|
||||
}
|
||||
pub fn close(&mut self) {
|
||||
self.0.close();
|
||||
}
|
||||
pub fn empty(&mut self, name: &str, attrs: &[(&str, &str)]) {
|
||||
self.0.empty(name, attrs);
|
||||
}
|
||||
/// `<name attrs>value</name>`.
|
||||
pub fn text_attrs(&mut self, name: &str, attrs: &[(&str, &str)], value: &str) {
|
||||
self.0.leaf(name, attrs, value);
|
||||
}
|
||||
pub fn text(&mut self, name: &str, value: impl std::fmt::Display) {
|
||||
self.text_attrs(name, &[], &value.to_string());
|
||||
}
|
||||
pub fn finish(self) -> String {
|
||||
self.0.finish()
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn writer_roundtrip() {
|
||||
let mut w = XmlWriter::new(Some("xmeml"));
|
||||
w.open("a", &[("x", "1 & 2")]);
|
||||
w.text("b", "<hi>");
|
||||
w.empty("c", &[]);
|
||||
let s = w.finish();
|
||||
assert_eq!(s, "<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n<!DOCTYPE xmeml>\n<a x=\"1 & 2\">\n <b><hi></b>\n <c/>\n</a>\n");
|
||||
let d = parse(&s).unwrap();
|
||||
let a = Node(&d);
|
||||
assert_eq!(a.attribute("x"), Some("1 & 2"));
|
||||
assert_eq!(child_text(a, "b"), Some("<hi>"));
|
||||
assert!(child(a, "c").is_some());
|
||||
assert_eq!(a.descendants().map(Node::name).collect::<Vec<_>>(), ["a", "b", "c"]);
|
||||
assert_ne!(child(a, "b").map(Node::id), child(a, "c").map(Node::id));
|
||||
assert_eq!(num(1.5), "1.5");
|
||||
assert_eq!(num(100.0), "100");
|
||||
assert_eq!(num(-0.0), "0");
|
||||
assert_eq!(num(0.1 + 0.2).parse::<f64>().unwrap(), 0.1 + 0.2);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn attr_whitespace_roundtrip() {
|
||||
let mut w = XmlWriter::new(None);
|
||||
w.open("a", &[("name", "x\ny\tz\r")]);
|
||||
w.text("b", "a\nb");
|
||||
let s = w.finish();
|
||||
let d = parse(&s).unwrap();
|
||||
let a = Node(&d);
|
||||
assert_eq!(a.attribute("name"), Some("x\ny\tz\r"));
|
||||
assert_eq!(child(a, "b").and_then(Node::text), Some("a\nb"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rejects_internal_dtd_entity_expansion() {
|
||||
let text = "<!DOCTYPE root [<!ENTITY word 'expanded'>]><root>&word;</root>";
|
||||
assert!(parse(text).is_err());
|
||||
assert!(parse("<root>&word;</root>").is_err(), "an entity XML does not define");
|
||||
assert!(parse("<!DOCTYPE xmeml>\n<xmeml version=\"4\"/>").is_ok());
|
||||
}
|
||||
|
||||
/// Elements nested beyond the limit are an error, not a deep recursion.
|
||||
#[test]
|
||||
fn nesting_is_bounded() {
|
||||
let nested = |depth: usize| format!("{}{}", "<a>".repeat(depth), "</a>".repeat(depth));
|
||||
assert!(parse(&nested(LIMITS.max_depth)).is_ok());
|
||||
let e = parse(&nested(LIMITS.max_depth + 1)).unwrap_err();
|
||||
assert!(e.contains("deeper"), "{e}");
|
||||
assert!(parse(&nested(100_000)).is_err());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,758 @@
|
||||
//! Final Cut Pro 7 XML (xmeml): a hand-written Premiere-style document (written from the public
|
||||
//! xmeml element descriptions) and export → import round trips.
|
||||
|
||||
use crate::support;
|
||||
|
||||
use craft_timeline::model::{
|
||||
BinEntry, ClipId, Generator, ItemId, ItemKind, Keyframe, Label, Marker, MarkerId, MarkerKind, MediaRef, Param, ParamValue, Project, Sequence,
|
||||
TrackKind, TransitionAlign,
|
||||
};
|
||||
use craft_timeline::time::{FrameRate, Tick};
|
||||
use craft_timeline::{ExportOptions, Format, Imported, Report, detect_text};
|
||||
use proptest::prelude::*;
|
||||
use support::*;
|
||||
|
||||
const RATE: &str = "<rate><timebase>24</timebase><ntsc>TRUE</ntsc></rate>";
|
||||
|
||||
fn premiere_style_doc() -> String {
|
||||
format!(
|
||||
r#"<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!DOCTYPE xmeml>
|
||||
<xmeml version="4">
|
||||
<project>
|
||||
<name>Lighthouse</name>
|
||||
<children>
|
||||
<bin>
|
||||
<name>Footage</name>
|
||||
<children>
|
||||
<clip id="masterclip-1">
|
||||
<name>tower.mov</name>
|
||||
<duration>2400</duration>
|
||||
{RATE}
|
||||
<labels><label2>Mango</label2></labels>
|
||||
<media><video><track><clipitem id="clipitem-m1"><name>tower.mov</name>
|
||||
<file id="file-1">
|
||||
<name>tower.mov</name>
|
||||
<pathurl>file://localhost/Volumes/Shoot/tower%20wide.mov</pathurl>
|
||||
{RATE}
|
||||
<duration>2400</duration>
|
||||
<timecode>{RATE}<string>01:00:00:00</string><frame>86400</frame><displayformat>NDF</displayformat></timecode>
|
||||
<media>
|
||||
<video><samplecharacteristics>{RATE}<width>3840</width><height>2160</height></samplecharacteristics></video>
|
||||
<audio><samplecharacteristics><depth>24</depth><samplerate>48000</samplerate></samplecharacteristics><channelcount>2</channelcount></audio>
|
||||
</media>
|
||||
</file>
|
||||
</clipitem></track></video></media>
|
||||
</clip>
|
||||
</children>
|
||||
</bin>
|
||||
<sequence id="sequence-1">
|
||||
<name>Main Edit</name>
|
||||
<duration>130</duration>
|
||||
{RATE}
|
||||
<timecode>{RATE}<string>01:00:00:00</string><frame>86400</frame><displayformat>NDF</displayformat></timecode>
|
||||
<media>
|
||||
<video>
|
||||
<format><samplecharacteristics>{RATE}<width>1920</width><height>1080</height><pixelaspectratio>square</pixelaspectratio></samplecharacteristics></format>
|
||||
<track>
|
||||
<clipitem id="clipitem-1">
|
||||
<masterclipid>masterclip-1</masterclipid>
|
||||
<name>tower.mov</name>
|
||||
<enabled>TRUE</enabled>
|
||||
{RATE}
|
||||
<start>0</start><end>-1</end><in>0</in><out>48</out>
|
||||
<file id="file-1"/>
|
||||
<link><linkclipref>clipitem-1</linkclipref><mediatype>video</mediatype><trackindex>1</trackindex><clipindex>1</clipindex></link>
|
||||
<link><linkclipref>clipitem-3</linkclipref><mediatype>audio</mediatype><trackindex>1</trackindex><clipindex>1</clipindex></link>
|
||||
<labels><label2>Cerulean</label2></labels>
|
||||
</clipitem>
|
||||
<transitionitem>
|
||||
{RATE}
|
||||
<start>36</start><end>60</end><alignment>center</alignment>
|
||||
<effect><name>Cross Dissolve</name><effectid>Cross Dissolve</effectid><effectcategory>Dissolve</effectcategory><effecttype>transition</effecttype><mediatype>video</mediatype></effect>
|
||||
</transitionitem>
|
||||
<clipitem id="clipitem-2">
|
||||
<name>waves</name>
|
||||
{RATE}
|
||||
<start>-1</start><end>120</end><in>100</in><out>172</out>
|
||||
<file id="file-2">
|
||||
<name>waves.mov</name>
|
||||
<pathurl>media/waves.mov</pathurl>
|
||||
{RATE}
|
||||
<duration>1000</duration>
|
||||
<media><video><samplecharacteristics><width>1920</width><height>1080</height></samplecharacteristics></video></media>
|
||||
</file>
|
||||
<filter><effect>
|
||||
<name>Basic Motion</name><effectid>basic</effectid><effectcategory>motion</effectcategory><effecttype>motion</effecttype><mediatype>video</mediatype>
|
||||
<parameter><parameterid>scale</parameterid><name>Scale</name><valuemin>0</valuemin><valuemax>1000</valuemax><value>100</value>
|
||||
<keyframe><when>100</when><value>100</value></keyframe>
|
||||
<keyframe><when>160</when><value>150</value></keyframe>
|
||||
</parameter>
|
||||
<parameter><parameterid>rotation</parameterid><name>Rotation</name><value>-12.5</value></parameter>
|
||||
<parameter><parameterid>center</parameterid><name>Center</name><value><horiz>0.25</horiz><vert>-0.1</vert></value></parameter>
|
||||
</effect></filter>
|
||||
<filter><effect>
|
||||
<name>Opacity</name><effectid>opacity</effectid><effectcategory>motion</effectcategory><effecttype>motion</effecttype><mediatype>video</mediatype>
|
||||
<parameter><parameterid>opacity</parameterid><name>opacity</name><valuemin>0</valuemin><valuemax>100</valuemax><value>50</value></parameter>
|
||||
</effect></filter>
|
||||
<filter><effect>
|
||||
<name>Gaussian Blur</name><effectid>GaussianBlur</effectid><effecttype>filter</effecttype><mediatype>video</mediatype>
|
||||
</effect></filter>
|
||||
<filter><effect>
|
||||
<name>Mystery Glow Pro</name><effectid>mystery</effectid><effecttype>filter</effecttype><mediatype>video</mediatype>
|
||||
</effect></filter>
|
||||
<marker><name>wave peak</name><comment>use this</comment><in>130</in><out>-1</out></marker>
|
||||
</clipitem>
|
||||
</track>
|
||||
<track>
|
||||
<clipitem id="clipitem-4">
|
||||
<name>Titles Nest</name>
|
||||
{RATE}
|
||||
<start>24</start><end>72</end><in>0</in><out>48</out>
|
||||
<sequence id="sequence-2"/>
|
||||
</clipitem>
|
||||
<generatoritem id="clipitem-7">
|
||||
<name>Red Matte</name>
|
||||
{RATE}
|
||||
<start>100</start><end>130</end><in>0</in><out>30</out>
|
||||
<effect><name>Color</name><effectid>Color</effectid><effectcategory>Matte</effectcategory><effecttype>generator</effecttype><mediatype>video</mediatype>
|
||||
<parameter><parameterid>fillcolor</parameterid><name>Color</name><value><alpha>255</alpha><red>255</red><green>0</green><blue>0</blue></value></parameter>
|
||||
</effect>
|
||||
</generatoritem>
|
||||
<enabled>TRUE</enabled>
|
||||
<locked>TRUE</locked>
|
||||
</track>
|
||||
</video>
|
||||
<audio>
|
||||
<track>
|
||||
<clipitem id="clipitem-3">
|
||||
<name>tower.mov</name>
|
||||
{RATE}
|
||||
<start>0</start><end>48</end><in>0</in><out>48</out>
|
||||
<file id="file-1"/>
|
||||
<sourcetrack><mediatype>audio</mediatype><trackindex>1</trackindex></sourcetrack>
|
||||
<filter><effect>
|
||||
<name>Audio Levels</name><effectid>audiolevels</effectid><effectcategory>audiolevels</effectcategory><effecttype>audiolevels</effecttype><mediatype>audio</mediatype>
|
||||
<parameter><parameterid>level</parameterid><name>Level</name><valuemin>0</valuemin><valuemax>3.98109</valuemax><value>0.5</value>
|
||||
<keyframe><when>0</when><value>1</value></keyframe>
|
||||
<keyframe><when>24</when><value>0.5</value></keyframe>
|
||||
</parameter>
|
||||
</effect></filter>
|
||||
<link><linkclipref>clipitem-1</linkclipref><mediatype>video</mediatype></link>
|
||||
<link><linkclipref>clipitem-3</linkclipref><mediatype>audio</mediatype></link>
|
||||
</clipitem>
|
||||
</track>
|
||||
<track>
|
||||
<clipitem id="clipitem-5">
|
||||
<name>music.wav</name>
|
||||
<enabled>FALSE</enabled>
|
||||
{RATE}
|
||||
<start>0</start><end>120</end><in>0</in><out>120</out>
|
||||
<file id="file-3"><name>music.wav</name><pathurl>file:///Volumes/Audio/music.wav</pathurl>
|
||||
<media><audio><samplecharacteristics><samplerate>44100</samplerate></samplecharacteristics><channelcount>2</channelcount></audio></media>
|
||||
</file>
|
||||
</clipitem>
|
||||
</track>
|
||||
</audio>
|
||||
</media>
|
||||
<marker><name>Check sky</name><comment></comment><in>30</in><out>-1</out></marker>
|
||||
</sequence>
|
||||
<sequence id="sequence-2">
|
||||
<name>Titles Nest</name>
|
||||
{RATE}
|
||||
<media>
|
||||
<video>
|
||||
<track>
|
||||
<clipitem id="clipitem-6">
|
||||
<name>waves fast</name>
|
||||
{RATE}
|
||||
<start>0</start><end>48</end><in>0</in><out>96</out>
|
||||
<file id="file-2"/>
|
||||
<filter><effect><name>Time Remap</name><effectid>timeremap</effectid><effectcategory>motion</effectcategory><effecttype>motion</effecttype><mediatype>video</mediatype>
|
||||
<parameter><parameterid>speed</parameterid><name>speed</name><value>200</value></parameter>
|
||||
<parameter><parameterid>reverse</parameterid><name>reverse</name><value>FALSE</value></parameter>
|
||||
</effect></filter>
|
||||
</clipitem>
|
||||
</track>
|
||||
</video>
|
||||
</media>
|
||||
</sequence>
|
||||
</children>
|
||||
</project>
|
||||
</xmeml>
|
||||
"#
|
||||
)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn golden_premiere_style_nested_and_transitions() {
|
||||
let doc = premiere_style_doc();
|
||||
assert_eq!(detect_text(doc.as_bytes(), Some("xml")), Some(Format::Fcp7Xml));
|
||||
let (imp, rep) = fcp7_import(&doc, Some("/proj"));
|
||||
let p = &imp.project;
|
||||
assert_eq!(p.name, "Lighthouse");
|
||||
assert_eq!(imp.sequences.len(), 2);
|
||||
let main = imp.sequences.iter().copied().find(|s| p.item(*s).unwrap().name == "Main Edit").unwrap();
|
||||
let nest = imp.sequences.iter().copied().find(|s| p.item(*s).unwrap().name == "Titles Nest").unwrap();
|
||||
let s = p.sequence(main).unwrap();
|
||||
let r = FrameRate::FPS_23_976;
|
||||
assert_eq!(s.settings.frame_rate, r);
|
||||
assert_eq!(s.start_timecode, 86_400);
|
||||
|
||||
assert_master_clip(p, r);
|
||||
assert_main_video_and_effects(p, s, r, &rep);
|
||||
assert_nested_and_audio(p, s, r, nest);
|
||||
}
|
||||
|
||||
fn assert_master_clip(p: &Project, r: FrameRate) {
|
||||
// Master clip in the Footage bin, with its file characteristics.
|
||||
let tower = p.items.values().find(|i| i.name == "tower.mov").unwrap();
|
||||
let tm = tower.as_media().unwrap();
|
||||
assert_eq!(tm.media, MediaRef::File { path: "/Volumes/Shoot/tower wide.mov".into() });
|
||||
assert_eq!(tm.info.video.as_ref().unwrap().width, 3840);
|
||||
assert_eq!(tm.info.start_timecode, Some(86_400));
|
||||
assert_eq!(tm.info.duration, r.tick_of(2400));
|
||||
assert_eq!(tower.label, Label::Mango);
|
||||
let bin = p.root.children.iter().find_map(|c| if let BinEntry::Bin(b) = c { Some(b) } else { None }).unwrap();
|
||||
assert_eq!(bin.name, "Footage");
|
||||
assert_eq!(p.root.parent_of(tower.id), Some(bin.id));
|
||||
}
|
||||
|
||||
fn assert_main_video_and_effects(p: &Project, s: &Sequence, r: FrameRate, rep: &Report) {
|
||||
// V1: -1 start/end resolved from in/out, centred dissolve.
|
||||
let v1 = &s.video_tracks[0];
|
||||
assert_eq!(v1.items.len(), 2);
|
||||
let (c1, c2) = (&v1.items[0], &v1.items[1]);
|
||||
assert_eq!((c1.start, c1.duration), (r.tick_of(0), r.tick_of(48)));
|
||||
assert_eq!((c2.start, c2.duration, c2.source_in), (r.tick_of(48), r.tick_of(72), r.tick_of(100)));
|
||||
assert_eq!(c1.label, Label::Cerulean);
|
||||
assert_eq!(media_key(p, c2.item), "/proj/media/waves.mov");
|
||||
let t = &v1.transitions[0];
|
||||
assert_eq!((t.start, t.duration, t.from, t.to, t.align), (r.tick_of(36), r.tick_of(24), Some(c1.id), Some(c2.id), TransitionAlign::CenterAtCut));
|
||||
assert_eq!(t.effect.effect, "cross_dissolve");
|
||||
// Motion / opacity / generic effect by name.
|
||||
let scale = c2.effect("motion").unwrap().param("scale").unwrap();
|
||||
assert_eq!(scale.keyframes.len(), 2);
|
||||
assert_eq!(scale.keyframes[1].time, r.tick_of(160));
|
||||
assert_eq!(scale.keyframes[1].value, ParamValue::Float(150.0));
|
||||
assert_eq!(c2.effect("motion").unwrap().param("rotation").unwrap().value, ParamValue::Float(-12.5));
|
||||
let pos = c2.effect("motion").unwrap().param("position").unwrap().value.as_vec2().unwrap();
|
||||
assert!((pos.x - (960.0 + 480.0)).abs() < 1e-9 && (pos.y - (540.0 - 108.0)).abs() < 1e-9, "{pos:?}");
|
||||
assert_eq!(c2.effect("opacity").unwrap().param("opacity").unwrap().value, ParamValue::Float(50.0));
|
||||
assert!(c2.effect("gaussian_blur").is_some());
|
||||
assert!(rep.mentions("Mystery Glow Pro"), "{rep}");
|
||||
assert_eq!(c2.markers.len(), 1);
|
||||
assert_eq!((c2.markers[0].name.as_str(), c2.markers[0].start), ("wave peak", r.tick_of(130)));
|
||||
}
|
||||
|
||||
fn assert_nested_and_audio(p: &Project, s: &Sequence, r: FrameRate, nest: ItemId) {
|
||||
// V2: nested sequence clip, colour matte generator, locked track.
|
||||
let v2 = &s.video_tracks[1];
|
||||
assert!(v2.locked);
|
||||
assert_eq!(v2.items[0].item, nest);
|
||||
assert!(matches!(p.item(v2.items[0].item).unwrap().kind, ItemKind::Sequence(_)));
|
||||
let g = p.item(v2.items[1].item).unwrap().as_media().unwrap();
|
||||
assert_eq!(g.media, MediaRef::Generator(Generator::ColorMatte { color: [1.0, 0.0, 0.0, 1.0] }));
|
||||
let ns = p.sequence(nest).unwrap();
|
||||
assert_eq!(ns.video_tracks[0].items[0].speed, 2.0);
|
||||
assert_eq!(ns.video_tracks[0].items[0].item, s.video_tracks[0].items[1].item, "file-2 reference shared across sequences");
|
||||
|
||||
// Audio: levels as dB with keyframes, link to the video clip, disabled music.
|
||||
let a1 = &s.audio_tracks[0].items[0];
|
||||
let lvl = a1.effect("volume").unwrap().param("level").unwrap();
|
||||
assert!((lvl.keyframes[1].value.as_f64().unwrap() + 6.0206).abs() < 1e-3);
|
||||
assert_eq!(lvl.keyframes[0].value, ParamValue::Float(0.0));
|
||||
assert!(a1.link.is_some());
|
||||
assert_eq!(a1.link, s.video_tracks[0].items[0].link);
|
||||
let music = &s.audio_tracks[1].items[0];
|
||||
assert!(!music.enabled);
|
||||
assert!(p.item(music.item).unwrap().as_media().unwrap().info.video.is_none());
|
||||
assert_eq!(s.markers[0].name, "Check sky");
|
||||
assert_eq!(s.markers[0].start, r.tick_of(30));
|
||||
}
|
||||
|
||||
fn rich_project() -> (Project, ItemId) {
|
||||
let r = FrameRate::FPS_29_97;
|
||||
let mut p = Project::new("P");
|
||||
let a = media(&mut p, "/m/a b.mov", true, true, r);
|
||||
let b = media(&mut p, "/m/b.mov", true, true, r);
|
||||
let m = media(&mut p, "/m/music.wav", false, true, r);
|
||||
let nest = sequence(&mut p, "Nested", r, true);
|
||||
clip(&mut p, nest, TrackKind::Video, 0, b, 0..90, 30);
|
||||
let s = sequence(&mut p, "Main", r, true);
|
||||
p.sequence_mut(s).unwrap().start_timecode = 107_892;
|
||||
let v1 = clip(&mut p, s, TrackKind::Video, 0, a, 0..60, 10);
|
||||
let a1 = clip(&mut p, s, TrackKind::Audio, 0, a, 0..60, 10);
|
||||
link(&mut p, s, &[v1, a1]);
|
||||
let v2 = clip(&mut p, s, TrackKind::Video, 0, b, 60..60 + (45), 300);
|
||||
transition(&mut p, s, (TrackKind::Video, 0), "dip_to_black", (Some(v1), Some(v2)), 50..50 + (20), TransitionAlign::CenterAtCut);
|
||||
transition(&mut p, s, (TrackKind::Video, 0), "cross_dissolve", (Some(v2), None), 90..90 + (15), TransitionAlign::EndAtCut);
|
||||
let n = clip(&mut p, s, TrackKind::Video, 1, nest, 20..20 + (40), 0);
|
||||
let mm = clip(&mut p, s, TrackKind::Audio, 1, m, 0..105, 0);
|
||||
transition(&mut p, s, (TrackKind::Audio, 1), "constant_power", (None, Some(mm)), 0..10, TransitionAlign::StartAtCut);
|
||||
{
|
||||
let q = p.sequence_mut(s).unwrap();
|
||||
let (_, it) = q.find_item_mut(v2).unwrap();
|
||||
it.speed = 1.5;
|
||||
it.label = Label::Rose;
|
||||
let mut sc = Param::new(ParamValue::Float(100.0));
|
||||
sc.keyframes.push(Keyframe::new(r.tick_of(300), ParamValue::Float(100.0)));
|
||||
sc.keyframes.push(Keyframe::new(r.tick_of(330), ParamValue::Float(80.0)));
|
||||
it.effect_mut("motion").unwrap().params.insert("scale".into(), sc);
|
||||
it.effect_mut("opacity").unwrap().params.insert("opacity".into(), Param::new(ParamValue::Float(75.0)));
|
||||
it.effects.push(instance("gaussian_blur"));
|
||||
it.markers.push(Marker {
|
||||
url: String::new(),
|
||||
author: String::new(),
|
||||
ext: None,
|
||||
id: MarkerId(900),
|
||||
start: r.tick_of(310),
|
||||
duration: r.tick_of(5),
|
||||
name: "hit".into(),
|
||||
comment: "c".into(),
|
||||
kind: MarkerKind::Comment,
|
||||
color: Label::Yellow,
|
||||
});
|
||||
let (_, ni) = q.find_item_mut(n).unwrap();
|
||||
ni.enabled = false;
|
||||
ni.reverse = true;
|
||||
let (_, ai) = q.find_item_mut(a1).unwrap();
|
||||
ai.effect_mut("volume").unwrap().params.insert("level".into(), Param::new(ParamValue::Float(-6.0)));
|
||||
q.markers.push(Marker {
|
||||
url: String::new(),
|
||||
author: String::new(),
|
||||
ext: None,
|
||||
id: MarkerId(901),
|
||||
start: r.tick_of(12),
|
||||
duration: Tick::ZERO,
|
||||
name: "seq m".into(),
|
||||
comment: String::new(),
|
||||
kind: MarkerKind::Comment,
|
||||
color: Label::Blue,
|
||||
});
|
||||
q.video_tracks[1].locked = true;
|
||||
}
|
||||
(p, s)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rich_roundtrip() {
|
||||
let (p, s) = rich_project();
|
||||
let (imp, text, rep) = fcp7_roundtrip(&p, s, &xopts());
|
||||
assert!(text.starts_with("<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n<!DOCTYPE xmeml>\n<xmeml version=\"4\">"));
|
||||
assert!(text.contains("<pathurl>file://localhost/m/a%20b.mov</pathurl>"), "{text}");
|
||||
assert!(!rep.has_warnings(), "{rep}");
|
||||
let si = only_seq(&imp);
|
||||
let q = &imp.project;
|
||||
for kind in [TrackKind::Video, TrackKind::Audio] {
|
||||
assert_eq!(structure(q, si, kind), structure(&p, s, kind), "{text}");
|
||||
}
|
||||
assert_eq!(links(q, si), links(&p, s));
|
||||
let qs = q.sequence(si).unwrap();
|
||||
let ps = p.sequence(s).unwrap();
|
||||
assert_eq!(qs.start_timecode, ps.start_timecode);
|
||||
assert!(qs.settings.drop_frame);
|
||||
assert_eq!(
|
||||
qs.markers.iter().map(|m| (&m.name, m.start, m.color)).collect::<Vec<_>>(),
|
||||
ps.markers.iter().map(|m| (&m.name, m.start, m.color)).collect::<Vec<_>>()
|
||||
);
|
||||
assert!(qs.video_tracks[1].locked);
|
||||
let v2 = &qs.video_tracks[0].items[1];
|
||||
let pv2 = &ps.video_tracks[0].items[1];
|
||||
assert_eq!(v2.label, Label::Rose);
|
||||
assert_eq!(v2.effect("motion").unwrap().param("scale"), pv2.effect("motion").unwrap().param("scale"));
|
||||
assert_eq!(v2.effect("opacity").unwrap().param("opacity").unwrap().value, ParamValue::Float(75.0));
|
||||
assert!(v2.effect("gaussian_blur").is_some());
|
||||
assert_eq!(
|
||||
v2.markers.iter().map(|m| (&m.name, m.start, m.duration)).collect::<Vec<_>>(),
|
||||
pv2.markers.iter().map(|m| (&m.name, m.start, m.duration)).collect::<Vec<_>>()
|
||||
);
|
||||
let lvl = qs.audio_tracks[0].items[0].effect("volume").unwrap().param("level").unwrap().value.as_f64().unwrap();
|
||||
assert!((lvl + 6.0).abs() < 1e-9);
|
||||
// nested sequence survived as a sequence item with its own clip
|
||||
let nested = qs.video_tracks[1].items[0].item;
|
||||
assert_eq!(q.item(nested).unwrap().name, "Nested");
|
||||
assert_eq!(q.sequence(nested).unwrap().video_tracks[0].items[0].source_in, FrameRate::FPS_29_97.tick_of(30));
|
||||
// Re-export is stable.
|
||||
let (again, _) = fcp7_export(q, si, &xopts());
|
||||
let (imp2, _) = fcp7_import(&again, None);
|
||||
assert_eq!(structure(&imp2.project, imp2.sequences[0], TrackKind::Video), structure(&p, s, TrackKind::Video));
|
||||
}
|
||||
|
||||
/// filmcraft#1868: a filter is written by the catalog's stable name, which other applications read back.
|
||||
#[test]
|
||||
fn grading_keeps_its_interchange_name() {
|
||||
let (mut p, s) = rich_project();
|
||||
let first = p.sequence(s).unwrap().video_tracks[0].items[0].id;
|
||||
let (_, it) = p.sequence_mut(s).unwrap().find_item_mut(first).unwrap();
|
||||
it.effects.push(instance("lumetri"));
|
||||
let (fcp7, _) = fcp7_export(&p, s, &xopts());
|
||||
assert!(fcp7.contains("<name>Lumetri Color</name>"), "{fcp7}");
|
||||
assert!(fcp7.contains("<effectcategory>Color Correction</effectcategory>"), "{fcp7}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn version_5_and_generators() {
|
||||
let (mut p, s) = rich_project();
|
||||
let g = generator(&mut p, "Bars", Generator::BarsAndTone);
|
||||
let k = generator(&mut p, "Black", Generator::BlackVideo);
|
||||
clip(&mut p, s, TrackKind::Video, 2, g, 0..30, 0);
|
||||
clip(&mut p, s, TrackKind::Video, 2, k, 30..30 + (30), 0);
|
||||
let o = ExportOptions { xmeml_version: 5, ..xopts() };
|
||||
let (imp, text, _) = fcp7_roundtrip(&p, s, &o);
|
||||
assert!(text.contains("<xmeml version=\"5\">"));
|
||||
assert!(text.contains("<effectid>slug</effectid>"));
|
||||
let si = only_seq(&imp);
|
||||
assert_eq!(structure(&imp.project, si, TrackKind::Video), structure(&p, s, TrackKind::Video));
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------------------------
|
||||
// Property round trip over multi-track timelines with transitions, speed, links, enable flags.
|
||||
// ---------------------------------------------------------------------------------------------
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
struct G {
|
||||
track: usize,
|
||||
gap: i64,
|
||||
dur: i64,
|
||||
media: usize,
|
||||
src: i64,
|
||||
speed: u8,
|
||||
enabled: bool,
|
||||
tr: u8,
|
||||
tr_len: i64,
|
||||
audio: bool,
|
||||
}
|
||||
|
||||
fn gen_clip() -> impl Strategy<Value = G> {
|
||||
(0usize..3, 0i64..3, 20i64..150, 0usize..3, 0i64..2000, 0u8..5, prop::bool::weighted(0.9), 0u8..6, 1i64..9, any::<bool>()).prop_map(
|
||||
|(track, gap, dur, media, src, speed, enabled, tr, tr_len, audio)| G {
|
||||
track,
|
||||
gap: gap * 5,
|
||||
dur,
|
||||
media,
|
||||
src,
|
||||
speed,
|
||||
enabled,
|
||||
tr,
|
||||
tr_len,
|
||||
audio,
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
proptest! {
|
||||
#![proptest_config(ProptestConfig::with_cases(48))]
|
||||
#[test]
|
||||
fn xmeml_roundtrip(clips in prop::collection::vec(gen_clip(), 1..20), ri in 0usize..5) {
|
||||
let rate = [FrameRate::FPS_23_976, FrameRate::FPS_25, FrameRate::FPS_29_97, FrameRate::FPS_59_94, FrameRate::FPS_24][ri];
|
||||
let mut p = Project::new("P");
|
||||
let ms = [media(&mut p, "/x/one.mov", true, true, rate), media(&mut p, "/x/two.mov", true, true, rate), media(&mut p, "/y/three four.mxf", true, true, rate)];
|
||||
let s = sequence(&mut p, "S", rate, false);
|
||||
let mut ends = [0i64; 3];
|
||||
let mut prev: [Option<ClipId>; 3] = [None; 3];
|
||||
let mut aends = [0i64; 3];
|
||||
for g in &clips {
|
||||
let t = ends[g.track] + g.gap;
|
||||
let v = clip(&mut p, s, TrackKind::Video, g.track, ms[g.media], t..t + (g.dur), g.src);
|
||||
{
|
||||
let q = p.sequence_mut(s).unwrap();
|
||||
let (_, it) = q.find_item_mut(v).unwrap();
|
||||
it.enabled = g.enabled;
|
||||
match g.speed {
|
||||
1 => it.speed = 2.0,
|
||||
2 => it.speed = 0.5,
|
||||
3 => it.reverse = true,
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
let d = g.tr_len;
|
||||
let adjacent = g.gap == 0 && prev[g.track].is_some();
|
||||
match g.tr {
|
||||
0 if adjacent => transition(&mut p, s, (TrackKind::Video, g.track), "cross_dissolve", (prev[g.track], Some(v)), t - d / 2..t - d / 2 + (d), TransitionAlign::CenterAtCut),
|
||||
1 if adjacent => transition(&mut p, s, (TrackKind::Video, g.track), "dip_to_white", (prev[g.track], Some(v)), t..t + (d), TransitionAlign::StartAtCut),
|
||||
2 if adjacent => transition(&mut p, s, (TrackKind::Video, g.track), "push", (prev[g.track], Some(v)), t - d..t - d + (d), TransitionAlign::EndAtCut),
|
||||
3 if !adjacent => transition(&mut p, s, (TrackKind::Video, g.track), "cross_dissolve", (None, Some(v)), t..t + (d), TransitionAlign::StartAtCut),
|
||||
_ => {}
|
||||
}
|
||||
if g.audio && aends[g.track] <= t {
|
||||
let a = clip(&mut p, s, TrackKind::Audio, g.track, ms[g.media], t..t + (g.dur), g.src);
|
||||
link(&mut p, s, &[v, a]);
|
||||
aends[g.track] = t + g.dur;
|
||||
}
|
||||
ends[g.track] = t + g.dur;
|
||||
prev[g.track] = Some(v);
|
||||
}
|
||||
let (imp, text, _) = fcp7_roundtrip(&p, s, &xopts());
|
||||
let si = only_seq(&imp);
|
||||
prop_assert_eq!(structure(&imp.project, si, TrackKind::Video), structure(&p, s, TrackKind::Video), "{}", text);
|
||||
prop_assert_eq!(structure(&imp.project, si, TrackKind::Audio), structure(&p, s, TrackKind::Audio));
|
||||
prop_assert_eq!(links(&imp.project, si), links(&p, s));
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_master_clip_used_by_an_earlier_sequence_still_lands_in_its_bin() {
|
||||
// Premiere and other writers may put a stringout sequence ahead of the bins that hold its clips.
|
||||
let file = format!(
|
||||
r#"<file id="file-1"><name>take.mov</name><pathurl>file://localhost/media/take.mov</pathurl>{RATE}<duration>120</duration><media><video><samplecharacteristics><width>1920</width><height>1080</height></samplecharacteristics></video></media></file>"#
|
||||
);
|
||||
let doc = format!(
|
||||
r#"<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!DOCTYPE xmeml>
|
||||
<xmeml version="4"><project><name>Order</name><children>
|
||||
<bin><name>Stringouts</name><children>
|
||||
<sequence id="sequence-1"><name>All takes</name><duration>120</duration>{RATE}
|
||||
<media><video><track><clipitem id="clipitem-1"><masterclipid>masterclip-1</masterclipid><name>take</name>{RATE}
|
||||
<start>0</start><end>120</end><in>0</in><out>120</out>{file}</clipitem></track></video></media>
|
||||
</sequence>
|
||||
</children></bin>
|
||||
<bin><name>Footage</name><children><bin><name>Comedy</name><children>
|
||||
<clip id="masterclip-1"><name>take</name><duration>120</duration>{RATE}
|
||||
<media><video><track><clipitem id="clipitem-2"><masterclipid>masterclip-1</masterclipid><name>take</name>{RATE}<file id="file-1"/></clipitem></track></video></media>
|
||||
</clip>
|
||||
</children></bin></children></bin>
|
||||
</children></project></xmeml>"#
|
||||
);
|
||||
let (imp, _) = fcp7_import(&doc, None);
|
||||
let p = &imp.project;
|
||||
let mut items = Vec::new();
|
||||
p.root.all_items(&mut items);
|
||||
let take = items.iter().copied().find(|i| p.item(*i).unwrap().name == "take.mov").unwrap();
|
||||
assert_eq!(items.iter().filter(|i| **i == take).count(), 1);
|
||||
let bin = p.root.parent_of(take).unwrap();
|
||||
assert_ne!(bin, p.root.id, "the take was left loose at the top");
|
||||
assert_eq!(p.root.find_bin(bin).unwrap().name, "Comedy");
|
||||
}
|
||||
|
||||
/// `start`/`end`/`in`/`out` of every sequence clip item in an exported document.
|
||||
fn clip_spans(xml: &str) -> Vec<(i64, i64, i64, i64)> {
|
||||
let tag = |block: &str, name: &str| -> i64 {
|
||||
let open = format!("<{name}>");
|
||||
let at = block.find(&open).unwrap_or_else(|| panic!("no <{name}> in {block}")) + open.len();
|
||||
block[at..].split('<').next().unwrap().trim().parse().unwrap()
|
||||
};
|
||||
xml.split("<clipitem").skip(1).filter(|b| b.contains("<start>")).map(|b| (tag(b, "start"), tag(b, "end"), tag(b, "in"), tag(b, "out"))).collect()
|
||||
}
|
||||
|
||||
/// filmcraft#340: a clip placed by seconds in a 23.976 sequence has sub-frame start, duration and source in.
|
||||
/// Its record span and source span were rounded to frames independently, so `out - in` could come
|
||||
/// out a frame shorter than `end - start` (Premiere then reads a different source range).
|
||||
#[test]
|
||||
fn sub_frame_clips_keep_equal_source_and_record_spans() {
|
||||
const SECOND: i64 = 254_016_000_000;
|
||||
let r = FrameRate::FPS_23_976;
|
||||
let mut p = Project::new("P");
|
||||
let a = media(&mut p, "/m/a.mov", true, true, r);
|
||||
let s = sequence(&mut p, "t", r, false);
|
||||
let c = clip(&mut p, s, TrackKind::Video, 0, a, 0..24, 0);
|
||||
// the issue's case first (start 0, source in 20 s, 3 s long), then starts, ins and lengths in tenths of a second
|
||||
let mut cases = vec![(0, 200, 30)];
|
||||
for start in [0, 1, 5, 17, 33] {
|
||||
for src in [0, 3, 7, 200, 413] {
|
||||
for dur in [1, 4, 30, 71, 125] {
|
||||
cases.push((start, src, dur));
|
||||
}
|
||||
}
|
||||
}
|
||||
for (start, src, dur) in cases {
|
||||
let (_, it) = p.sequence_mut(s).unwrap().find_item_mut(c).unwrap();
|
||||
it.start = Tick(start * SECOND / 10);
|
||||
it.source_in = Tick(src * SECOND / 10);
|
||||
it.duration = Tick(dur * SECOND / 10);
|
||||
let (xml, _) = fcp7_export(&p, s, &xopts());
|
||||
let spans = clip_spans(&xml);
|
||||
assert_eq!(spans.len(), 1, "{xml}");
|
||||
let (start_f, end_f, in_f, out_f) = spans[0];
|
||||
assert_eq!(out_f - in_f, end_f - start_f, "start {start}, in {src}, duration {dur} (tenths of a second): {spans:?}");
|
||||
}
|
||||
}
|
||||
|
||||
/// filmcraft#463: a stereo file split onto two mono tracks (A1 = channel 1, A2 = channel 2) lost the right channel,
|
||||
/// because every audio clip item was written with `sourcetrack/trackindex` 1 and the importer ignored it.
|
||||
#[test]
|
||||
fn split_mono_tracks_keep_their_source_channel() {
|
||||
let r = FrameRate::FPS_23_976;
|
||||
let mut p = Project::new("P");
|
||||
let a = media(&mut p, "/m/a.wav", false, true, r);
|
||||
let s = sequence(&mut p, "t", r, false);
|
||||
let left = clip(&mut p, s, TrackKind::Audio, 0, a, 0..24, 0);
|
||||
let right = clip(&mut p, s, TrackKind::Audio, 1, a, 0..24, 0);
|
||||
for (id, ch) in [(left, 0u16), (right, 1u16)] {
|
||||
let (_, it) = p.sequence_mut(s).unwrap().find_item_mut(id).unwrap();
|
||||
it.source_channels = vec![ch];
|
||||
}
|
||||
let (imp, xml, _) = fcp7_roundtrip(&p, s, &xopts());
|
||||
assert!(xml.contains("<trackindex>2</trackindex>"), "{xml}");
|
||||
let seq = imp.project.sequence(only_seq(&imp)).unwrap();
|
||||
let channels = |t: usize| seq.tracks(TrackKind::Audio)[t].items.iter().map(|i| i.source_channels.clone()).collect::<Vec<_>>();
|
||||
assert_eq!(channels(0), vec![vec![0u16]], "{xml}");
|
||||
assert_eq!(channels(1), vec![vec![1u16]], "{xml}");
|
||||
let (again, _) = fcp7_export(&imp.project, only_seq(&imp), &xopts());
|
||||
assert!(again.contains("<trackindex>2</trackindex>"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn audio_sourcetrack_trackindex_2_imports_as_source_channel_1() {
|
||||
let doc = format!(
|
||||
r#"<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!DOCTYPE xmeml>
|
||||
<xmeml version="4"><sequence id="sequence-1"><name>S</name><duration>48</duration>{RATE}
|
||||
<media><audio><track><clipitem id="clipitem-1"><name>take</name>{RATE}<start>0</start><end>48</end><in>0</in><out>48</out>
|
||||
<file id="file-1"><name>take.wav</name><pathurl>file://localhost/media/take.wav</pathurl>{RATE}<duration>48</duration><media><audio><channelcount>2</channelcount></audio></media></file>
|
||||
<sourcetrack><mediatype>audio</mediatype><trackindex>2</trackindex></sourcetrack>
|
||||
</clipitem></track></audio></media>
|
||||
</sequence></xmeml>"#
|
||||
);
|
||||
let (imp, _) = fcp7_import(&doc, None);
|
||||
let seq = imp.project.sequence(only_seq(&imp)).unwrap();
|
||||
assert_eq!(seq.tracks(TrackKind::Audio)[0].items[0].source_channels, vec![1u16]);
|
||||
}
|
||||
|
||||
/// filmcraft#463: Premiere writes a stereo file split onto two mono tracks as clip items with `trackindex` 1 and 2;
|
||||
/// a file used only with `trackindex` 1 is a stereo clip and keeps the default (all channels) mapping.
|
||||
#[test]
|
||||
fn audio_trackindex_1_is_channel_0_only_when_the_file_is_split() {
|
||||
let doc = format!(
|
||||
r#"<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!DOCTYPE xmeml>
|
||||
<xmeml version="4"><sequence id="sequence-1"><name>S</name><duration>48</duration>{RATE}
|
||||
<media><audio>
|
||||
<track><clipitem id="clipitem-1"><name>take</name>{RATE}<start>0</start><end>48</end><in>0</in><out>48</out>
|
||||
<file id="file-1"><name>take.wav</name><pathurl>file://localhost/media/take.wav</pathurl>{RATE}<duration>48</duration><media><audio><channelcount>2</channelcount></audio></media></file>
|
||||
<sourcetrack><mediatype>audio</mediatype><trackindex>1</trackindex></sourcetrack>
|
||||
</clipitem>
|
||||
<clipitem id="clipitem-3"><name>other</name>{RATE}<start>48</start><end>96</end><in>0</in><out>48</out>
|
||||
<file id="file-2"><name>other.wav</name><pathurl>file://localhost/media/other.wav</pathurl>{RATE}<duration>48</duration><media><audio><channelcount>2</channelcount></audio></media></file>
|
||||
<sourcetrack><mediatype>audio</mediatype><trackindex>1</trackindex></sourcetrack>
|
||||
</clipitem></track>
|
||||
<track><clipitem id="clipitem-2"><name>take</name>{RATE}<start>0</start><end>48</end><in>0</in><out>48</out>
|
||||
<file id="file-1"/>
|
||||
<sourcetrack><mediatype>audio</mediatype><trackindex>2</trackindex></sourcetrack>
|
||||
</clipitem></track>
|
||||
</audio></media>
|
||||
</sequence></xmeml>"#
|
||||
);
|
||||
let (imp, _) = fcp7_import(&doc, None);
|
||||
let seq = imp.project.sequence(only_seq(&imp)).unwrap();
|
||||
let a1 = &seq.tracks(TrackKind::Audio)[0].items;
|
||||
assert_eq!(a1[0].source_channels, vec![0u16]);
|
||||
assert!(a1[1].source_channels.is_empty(), "{:?}", a1[1].source_channels);
|
||||
assert_eq!(seq.tracks(TrackKind::Audio)[1].items[0].source_channels, vec![1u16]);
|
||||
}
|
||||
|
||||
/// A Premiere-style stereo file split onto two mono tracks (A1 = `trackindex` 1, A2 = 2).
|
||||
fn split_stereo_doc(pan_a1: &str) -> String {
|
||||
format!(
|
||||
r#"<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!DOCTYPE xmeml>
|
||||
<xmeml version="4"><sequence id="sequence-1"><name>S</name><duration>48</duration>{RATE}
|
||||
<media><audio>
|
||||
<track currentExplodedTrackIndex="0" totalExplodedTrackCount="2" premiereTrackType="Stereo"><clipitem id="clipitem-1"><name>sfx</name>{RATE}<start>0</start><end>48</end><in>0</in><out>48</out>
|
||||
<file id="file-1"><name>sfx.wav</name><pathurl>file://localhost/media/sfx.wav</pathurl>{RATE}<duration>48</duration><media><audio><channelcount>2</channelcount></audio></media></file>
|
||||
<sourcetrack><mediatype>audio</mediatype><trackindex>1</trackindex></sourcetrack>{pan_a1}
|
||||
</clipitem><outputchannelindex>1</outputchannelindex></track>
|
||||
<track currentExplodedTrackIndex="1" totalExplodedTrackCount="2" premiereTrackType="Stereo"><clipitem id="clipitem-2"><name>sfx</name>{RATE}<start>0</start><end>48</end><in>0</in><out>48</out>
|
||||
<file id="file-1"/>
|
||||
<sourcetrack><mediatype>audio</mediatype><trackindex>2</trackindex></sourcetrack>
|
||||
</clipitem><outputchannelindex>2</outputchannelindex></track>
|
||||
</audio></media>
|
||||
</sequence></xmeml>"#
|
||||
)
|
||||
}
|
||||
|
||||
fn balances(imp: &Imported) -> Vec<f64> {
|
||||
let seq = imp.project.sequence(only_seq(imp)).unwrap();
|
||||
seq.tracks(TrackKind::Audio)
|
||||
.iter()
|
||||
.flat_map(|t| &t.items)
|
||||
.map(|i| i.effect("panner").and_then(|e| e.param("balance")).and_then(|p| p.value.as_f64()).unwrap_or(0.0))
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// filmcraft#371: the halves of a split stereo file played centred, so both channels summed on both sides
|
||||
/// (+3 dB for a stereo effect, +6 dB for dual-mono dialogue). They now play on their own side.
|
||||
#[test]
|
||||
fn split_stereo_halves_are_panned_to_their_side() {
|
||||
let (imp, _) = fcp7_import(&split_stereo_doc(""), None);
|
||||
assert_eq!(balances(&imp), vec![-100.0, 100.0]);
|
||||
}
|
||||
|
||||
/// An Audio Pan filter on a split half still wins.
|
||||
#[test]
|
||||
fn split_stereo_half_keeps_its_audio_pan() {
|
||||
let pan = r#"<filter><effect><name>Audio Pan</name><effectid>audiopan</effectid><effectcategory>audiopan</effectcategory><effecttype>audiopan</effecttype><mediatype>audio</mediatype>
|
||||
<parameter><parameterid>pan</parameterid><name>Pan</name><valuemin>-1</valuemin><valuemax>1</valuemax><value>0</value></parameter></effect></filter>"#;
|
||||
let (imp, _) = fcp7_import(&split_stereo_doc(pan), None);
|
||||
assert_eq!(balances(&imp), vec![0.0, 100.0]);
|
||||
}
|
||||
|
||||
/// A centred one-channel clip (Breakout to Mono) round-trips centred: export writes its Audio Pan.
|
||||
#[test]
|
||||
fn centred_mono_breakout_round_trips_centred() {
|
||||
let r = FrameRate::FPS_23_976;
|
||||
let mut p = Project::new("P");
|
||||
let a = media(&mut p, "/m/a.wav", false, true, r);
|
||||
let s = sequence(&mut p, "t", r, false);
|
||||
let left = clip(&mut p, s, TrackKind::Audio, 0, a, 0..24, 0);
|
||||
let right = clip(&mut p, s, TrackKind::Audio, 1, a, 0..24, 0);
|
||||
for (id, ch) in [(left, 0u16), (right, 1u16)] {
|
||||
let (_, it) = p.sequence_mut(s).unwrap().find_item_mut(id).unwrap();
|
||||
it.source_channels = vec![ch];
|
||||
}
|
||||
let (imp, xml, _) = fcp7_roundtrip(&p, s, &xopts());
|
||||
assert_eq!(balances(&imp), vec![0.0, 0.0], "{xml}");
|
||||
}
|
||||
|
||||
/// A `timebase` near `i64::MAX` made a rate of ~9e18 per second: with `ntsc` the importer multiplied it by
|
||||
/// 1000 (overflow), without it the export overflowed adding 1 to a clamped frame index. The timebase is
|
||||
/// limited instead, and the document imports and exports.
|
||||
#[test]
|
||||
fn absurd_timebase_is_bounded() {
|
||||
for (tb, ntsc) in [("9223372036854775807", "TRUE"), ("9223372036854775807", "FALSE"), ("4294967296", "FALSE"), ("-5", "TRUE")] {
|
||||
let doc = format!(
|
||||
r#"<?xml version="1.0"?><xmeml version="4"><sequence id="s"><name>S</name><rate><timebase>{tb}</timebase><ntsc>{ntsc}</ntsc></rate>
|
||||
<timecode><rate><timebase>{tb}</timebase><ntsc>{ntsc}</ntsc></rate><string>01:00:00:00</string><frame>90000</frame></timecode>
|
||||
<media><video><track><clipitem id="c"><name>A</name><start>0</start><end>100</end><in>0</in><out>100</out>
|
||||
<file id="f"><name>a.mov</name><pathurl>file:///tmp/a.mov</pathurl><rate><timebase>{tb}</timebase><ntsc>{ntsc}</ntsc></rate><media><video/></media></file>
|
||||
<marker><name>m</name><in>5</in><out>-1</out></marker></clipitem></track></video></media></sequence></xmeml>"#
|
||||
);
|
||||
let (imported, _) = fcp7_import(&doc, None);
|
||||
for seq in &imported.sequences {
|
||||
fcp7_export(&imported.project, *seq, &xopts());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// A start timecode of 18446744073709551615 frames saturates to `i64::MAX`; the EDL export then
|
||||
/// overflowed adding frame numbers to it. Out-of-range start timecodes are reset with a warning.
|
||||
#[test]
|
||||
fn absurd_start_timecode_is_bounded() {
|
||||
let doc = r#"<?xml version="1.0"?><xmeml version="4"><sequence id="s"><name>S</name><rate><timebase>25</timebase><ntsc>FALSE</ntsc></rate>
|
||||
<timecode><rate><timebase>25</timebase><ntsc>FALSE</ntsc></rate><string>00:00:00:00</string><frame>18446744073709551615</frame></timecode>
|
||||
<media><video><track><clipitem id="c"><name>A</name><start>0</start><end>100</end><in>0</in><out>100</out>
|
||||
<file id="f"><name>a.mov</name><pathurl>file:///tmp/a.mov</pathurl><rate><timebase>25</timebase><ntsc>FALSE</ntsc></rate>
|
||||
<timecode><rate><timebase>25</timebase><ntsc>FALSE</ntsc></rate><string>00:00:00:00</string><frame>18446744073709551615</frame></timecode><media><video/></media></file>
|
||||
</clipitem></track></video></media></sequence></xmeml>"#;
|
||||
let (imported, report) = fcp7_import(doc, None);
|
||||
for seq in &imported.sequences {
|
||||
fcp7_export(&imported.project, *seq, &xopts());
|
||||
assert_eq!(imported.project.sequence(*seq).map(|s| s.start_timecode), Some(0));
|
||||
}
|
||||
assert!(report.mentions("out-of-range start timecode"), "{report}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_huge_link_group_does_not_write_quadratic_links() {
|
||||
let r = FrameRate::FPS_29_97;
|
||||
let mut p = Project::new("P");
|
||||
let a = media(&mut p, "/m/a.mov", true, true, r);
|
||||
let s = sequence(&mut p, "Main", r, true);
|
||||
let ids: Vec<_> = (0..300).map(|i| clip(&mut p, s, TrackKind::Audio, 0, a, 0..10, i * 10)).collect();
|
||||
link(&mut p, s, &ids);
|
||||
// the truncation warning is reported by the export, so it is the export's report that is checked
|
||||
let (text, rep) = fcp7_export(&p, s, &xopts());
|
||||
// only the first 64 clips are linked, each to the 64 members: the other 236 stay unlinked
|
||||
assert_eq!(text.matches("<linkclipref>").count(), 64 * 64);
|
||||
assert!(rep.has_warnings(), "{rep}");
|
||||
}
|
||||
@@ -0,0 +1,4 @@
|
||||
//! Integration tests, built as one test binary: each file is a module.
|
||||
|
||||
mod fcp7;
|
||||
mod support;
|
||||
@@ -0,0 +1,300 @@
|
||||
//! Test helpers: building projects, a catalog with a few application effects, and comparing edit
|
||||
//! structure.
|
||||
#![allow(dead_code)]
|
||||
|
||||
use std::ops::Range;
|
||||
|
||||
use craft_timeline::fx::Standard;
|
||||
use craft_timeline::model::{
|
||||
AudioStreamInfo, Catalog, ClipId, EffectDef, EffectInstance, EffectKind, Generator, ItemId, ItemKind, Label, MediaClip, MediaInfo, MediaKind,
|
||||
MediaRef, ParamDef, ParamValue, Project, Sequence, SequenceSettings, Track, TrackId, TrackItem, TrackKind, Transition, TransitionAlign,
|
||||
TransitionId, VideoStreamInfo,
|
||||
};
|
||||
use craft_timeline::time::{FrameRate, TICKS_PER_SECOND, Tick, TimeRange};
|
||||
use craft_timeline::{ExportOptions, ImportOptions, Imported, Report, fcp7};
|
||||
|
||||
/// [`Standard`] and a few effects an application adds: a blur, a grade, two transitions.
|
||||
pub struct TestCatalog;
|
||||
|
||||
fn extra() -> Vec<EffectDef> {
|
||||
let def = |id: &str, name: &str, kind, category: &str, params: Vec<ParamDef>| EffectDef {
|
||||
id: id.into(),
|
||||
name: name.into(),
|
||||
kind,
|
||||
category: category.into(),
|
||||
intrinsic: false,
|
||||
params,
|
||||
};
|
||||
let float = |id: &str, label: &str, v: f64| ParamDef { id: id.into(), label: label.into(), default: ParamValue::Float(v) };
|
||||
vec![
|
||||
def("gaussian_blur", "Gaussian Blur", EffectKind::Video, "Blur & Sharpen", vec![float("blurriness", "Blurriness", 0.0)]),
|
||||
def("lumetri", "Lumetri Color", EffectKind::Video, "Color Correction", vec![float("exposure", "Exposure", 0.0)]),
|
||||
def("dip_to_white", "Dip to White", EffectKind::VideoTransition, "Dissolve", vec![]),
|
||||
def("push", "Push", EffectKind::VideoTransition, "Slide", vec![]),
|
||||
]
|
||||
}
|
||||
|
||||
impl Catalog for TestCatalog {
|
||||
fn effect(&self, id: &str) -> Option<EffectDef> {
|
||||
Standard.effect(id).or_else(|| extra().into_iter().find(|d| d.id == id))
|
||||
}
|
||||
fn effect_by_name(&self, name: &str) -> Option<EffectDef> {
|
||||
Standard.effect_by_name(name).or_else(|| extra().into_iter().find(|d| d.name.eq_ignore_ascii_case(name)))
|
||||
}
|
||||
fn intrinsic(&self, kind: TrackKind) -> Vec<EffectInstance> {
|
||||
Standard.intrinsic(kind)
|
||||
}
|
||||
}
|
||||
|
||||
pub const NS: &str = "testapp";
|
||||
|
||||
pub fn iopts(base_dir: Option<&str>) -> ImportOptions<'_> {
|
||||
ImportOptions { base_dir, catalog: &TestCatalog, namespace: NS }
|
||||
}
|
||||
|
||||
pub fn xopts() -> ExportOptions<'static> {
|
||||
ExportOptions { relative_to: None, name: None, catalog: &TestCatalog, namespace: NS, xmeml_version: 4 }
|
||||
}
|
||||
|
||||
pub fn instance(id: &str) -> EffectInstance {
|
||||
TestCatalog.effect(id).unwrap().instance()
|
||||
}
|
||||
|
||||
pub fn media_info(name: &str, video: bool, audio: bool, rate: FrameRate, secs: i64) -> MediaInfo {
|
||||
MediaInfo {
|
||||
name: name.into(),
|
||||
kind: if video { MediaKind::Movie } else { MediaKind::AudioOnly },
|
||||
duration: Tick(secs * TICKS_PER_SECOND),
|
||||
video: video.then_some(VideoStreamInfo { width: 1920, height: 1080, frame_rate: rate, par: (1, 1) }),
|
||||
audio_streams: audio.then_some(AudioStreamInfo { sample_rate: 48_000, channels: 2, bits_per_sample: None }).into_iter().collect(),
|
||||
start_timecode: None,
|
||||
}
|
||||
}
|
||||
|
||||
fn media_clip(media: MediaRef, info: MediaInfo) -> ItemKind {
|
||||
ItemKind::Media(Box::new(MediaClip { media, info, interpret_frame_rate: None, markers: vec![], offline: false }))
|
||||
}
|
||||
|
||||
pub fn media(p: &mut Project, path: &str, video: bool, audio: bool, rate: FrameRate) -> ItemId {
|
||||
let name = path.rsplit('/').next().unwrap().to_string();
|
||||
let info = media_info(&name, video, audio, rate, 600);
|
||||
p.add_item(&name, Label::Iris, media_clip(MediaRef::File { path: path.into() }, info), None)
|
||||
}
|
||||
|
||||
pub fn generator(p: &mut Project, name: &str, g: Generator) -> ItemId {
|
||||
let mut info = media_info(name, true, false, FrameRate::FPS_24, 600);
|
||||
info.kind = MediaKind::Synthetic;
|
||||
p.add_item(name, Label::Lavender, media_clip(MediaRef::Generator(g), info), None)
|
||||
}
|
||||
|
||||
/// A sequence with three video and four audio tracks.
|
||||
pub fn sequence(p: &mut Project, name: &str, rate: FrameRate, df: bool) -> ItemId {
|
||||
let mut seq = Sequence::new(SequenceSettings { frame_rate: rate, drop_frame: df, ..Default::default() });
|
||||
for (kind, n, word) in [(TrackKind::Video, 3, "Video"), (TrackKind::Audio, 4, "Audio")] {
|
||||
for i in 0..n {
|
||||
let id = TrackId(p.alloc_id());
|
||||
seq.tracks_mut(kind).push(Track::new(id, kind, format!("{word} {}", i + 1)));
|
||||
}
|
||||
}
|
||||
p.add_item(name, Label::Forest, ItemKind::Sequence(Box::new(seq)), None)
|
||||
}
|
||||
|
||||
/// A clip of `item` as an editor puts one on a track: its intrinsic effects, its length snapped to frames.
|
||||
pub fn track_item(p: &mut Project, item: ItemId, kind: TrackKind, start: Tick, source: TimeRange, rate: FrameRate) -> TrackItem {
|
||||
let (name, label) = p.item(item).map(|i| (i.name.clone(), i.label)).unwrap();
|
||||
TrackItem {
|
||||
id: ClipId(p.alloc_id()),
|
||||
item,
|
||||
name,
|
||||
label,
|
||||
start,
|
||||
duration: rate.snap_nearest(source.duration).max(rate.frame_duration()),
|
||||
source_in: source.start,
|
||||
speed: 1.0,
|
||||
reverse: false,
|
||||
enabled: true,
|
||||
link: None,
|
||||
group: None,
|
||||
effects: TestCatalog.intrinsic(kind),
|
||||
markers: vec![],
|
||||
gain_db: 0.0,
|
||||
frame_hold: None,
|
||||
scale_to_frame: false,
|
||||
source_channels: vec![],
|
||||
ext: None,
|
||||
}
|
||||
}
|
||||
|
||||
/// Place a clip (frames at the sequence rate). Returns its id.
|
||||
pub fn clip(p: &mut Project, seq: ItemId, kind: TrackKind, track: usize, item: ItemId, frames: Range<i64>, src: i64) -> ClipId {
|
||||
let rate = p.sequence(seq).unwrap().settings.frame_rate;
|
||||
let duration = frames.end - frames.start;
|
||||
let ti = track_item(p, item, kind, rate.tick_of(frames.start), TimeRange::new(rate.tick_of(src), rate.tick_of(duration)), rate);
|
||||
let id = ti.id;
|
||||
let t = &mut p.sequence_mut(seq).unwrap().tracks_mut(kind)[track];
|
||||
t.items.push(ti);
|
||||
t.sort();
|
||||
id
|
||||
}
|
||||
|
||||
pub fn link(p: &mut Project, seq: ItemId, clips: &[ClipId]) {
|
||||
let l = p.alloc_id();
|
||||
let s = p.sequence_mut(seq).unwrap();
|
||||
for c in clips {
|
||||
s.find_item_mut(*c).unwrap().1.link = Some(l);
|
||||
}
|
||||
}
|
||||
|
||||
pub fn transition(
|
||||
p: &mut Project,
|
||||
seq: ItemId,
|
||||
track: (TrackKind, usize),
|
||||
effect: &str,
|
||||
clips: (Option<ClipId>, Option<ClipId>),
|
||||
frames: Range<i64>,
|
||||
align: TransitionAlign,
|
||||
) {
|
||||
let id = TransitionId(p.alloc_id());
|
||||
let rate = p.sequence(seq).unwrap().settings.frame_rate;
|
||||
let t = &mut p.sequence_mut(seq).unwrap().tracks_mut(track.0)[track.1];
|
||||
t.transitions.push(Transition {
|
||||
id,
|
||||
effect: instance(effect),
|
||||
start: rate.tick_of(frames.start),
|
||||
duration: rate.tick_of(frames.end - frames.start),
|
||||
from: clips.0,
|
||||
to: clips.1,
|
||||
align,
|
||||
reverse: false,
|
||||
ext: None,
|
||||
});
|
||||
t.sort();
|
||||
}
|
||||
|
||||
/// A comparable description of one clip.
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
pub struct C {
|
||||
pub start: Tick,
|
||||
pub dur: Tick,
|
||||
pub src: Tick,
|
||||
pub media: String,
|
||||
pub speed: String,
|
||||
pub reverse: bool,
|
||||
pub enabled: bool,
|
||||
}
|
||||
|
||||
/// A comparable description of one transition (from/to as clip indices on the track).
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
pub struct T {
|
||||
pub start: Tick,
|
||||
pub dur: Tick,
|
||||
pub effect: String,
|
||||
pub align: TransitionAlign,
|
||||
pub from: Option<usize>,
|
||||
pub to: Option<usize>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
pub struct Tr {
|
||||
pub clips: Vec<C>,
|
||||
pub transitions: Vec<T>,
|
||||
}
|
||||
|
||||
pub fn media_key(p: &Project, item: ItemId) -> String {
|
||||
match &p.item(item).unwrap().kind {
|
||||
ItemKind::Media(m) => match &m.media {
|
||||
MediaRef::File { path } => path.clone(),
|
||||
MediaRef::Generator(g) => format!("gen:{}", g.label()),
|
||||
},
|
||||
ItemKind::Sequence(_) => format!("seq:{}", p.item(item).unwrap().name),
|
||||
_ => "other".into(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Non-empty tracks of a sequence (trailing empty tracks ignored).
|
||||
pub fn structure(p: &Project, seq: ItemId, kind: TrackKind) -> Vec<Tr> {
|
||||
let s = p.sequence(seq).unwrap();
|
||||
let mut out: Vec<Tr> = s
|
||||
.tracks(kind)
|
||||
.iter()
|
||||
.map(|t| Tr {
|
||||
clips: t
|
||||
.items
|
||||
.iter()
|
||||
.map(|i| C {
|
||||
start: i.start,
|
||||
dur: i.duration,
|
||||
src: i.frame_hold.unwrap_or(i.source_in),
|
||||
media: media_key(p, i.item),
|
||||
speed: if i.frame_hold.is_some() { "hold".into() } else { format!("{:.3}", i.speed) },
|
||||
reverse: i.reverse,
|
||||
enabled: i.enabled,
|
||||
})
|
||||
.collect(),
|
||||
transitions: t
|
||||
.transitions
|
||||
.iter()
|
||||
.map(|x| T {
|
||||
start: x.start,
|
||||
dur: x.duration,
|
||||
effect: x.effect.effect.clone(),
|
||||
align: x.align,
|
||||
from: x.from.and_then(|f| t.items.iter().position(|i| i.id == f)),
|
||||
to: x.to.and_then(|f| t.items.iter().position(|i| i.id == f)),
|
||||
})
|
||||
.collect(),
|
||||
})
|
||||
.collect();
|
||||
while out.last().is_some_and(|t| t.clips.is_empty()) {
|
||||
out.pop();
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// Link partition as sets of (kind, track, clip index).
|
||||
pub fn links(p: &Project, seq: ItemId) -> Vec<Vec<(u8, usize, usize)>> {
|
||||
let s = p.sequence(seq).unwrap();
|
||||
let mut groups: std::collections::BTreeMap<u64, Vec<(u8, usize, usize)>> = Default::default();
|
||||
for (k, kind) in [(0u8, TrackKind::Video), (1, TrackKind::Audio)] {
|
||||
for (ti, t) in s.tracks(kind).iter().enumerate() {
|
||||
for (ci, c) in t.items.iter().enumerate() {
|
||||
if let Some(l) = c.link {
|
||||
groups.entry(l).or_default().push((k, ti, ci));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
let mut v: Vec<Vec<_>> = groups.into_values().filter(|g| g.len() > 1).collect();
|
||||
for g in &mut v {
|
||||
g.sort();
|
||||
}
|
||||
v.sort();
|
||||
v
|
||||
}
|
||||
|
||||
pub fn fcp7_export(p: &Project, seq: ItemId, opts: &ExportOptions) -> (String, Report) {
|
||||
let mut report = Report::default();
|
||||
let text = fcp7::export(p, seq, opts, &mut report).expect("export");
|
||||
(text, report)
|
||||
}
|
||||
|
||||
pub fn fcp7_import(text: &str, base_dir: Option<&str>) -> (Imported, Report) {
|
||||
let mut report = Report::default();
|
||||
let imp = fcp7::import(text, &iopts(base_dir), &mut report).unwrap_or_else(|e| panic!("import failed: {e}\n{text}"));
|
||||
(imp, report)
|
||||
}
|
||||
|
||||
pub fn fcp7_roundtrip(p: &Project, seq: ItemId, opts: &ExportOptions) -> (Imported, String, Report) {
|
||||
let (text, _) = fcp7_export(p, seq, opts);
|
||||
let (imp, rep) = fcp7_import(&text, None);
|
||||
(imp, text, rep)
|
||||
}
|
||||
|
||||
pub fn only_seq(imp: &Imported) -> ItemId {
|
||||
assert_eq!(imp.sequences.len(), 1, "expected one top-level sequence");
|
||||
imp.sequences[0]
|
||||
}
|
||||
|
||||
pub fn f(rate: FrameRate, n: i64) -> Tick {
|
||||
rate.tick_of(n)
|
||||
}
|
||||
Reference in new issue
Block a user