otio: write importable OTIO/FCPXML (DaVinci Resolve rejects both)
- available_range declared a zero duration for every clip (the media was 'unusable', so Resolve invalidated all of them): it now covers the whole media - video clips in the probed frame rate, audio clips in the probed sample rate (no more hardcoded 48000/0). - Gap ranges mixed time bases (a 24 fps block start, a 1.0 trailing duration): gaps now stay in the sequence's time base, the trailing pad in whole frames. - Empty sequences (no clips) are skipped, so a lone edited sequence exports as a bare Timeline root instead of a SerializableCollection Resolve cannot read. - FCPXML: an asset used on both video and audio tracks now declares hasVideo AND hasAudio (the first registration's track kind used to hide the other stream); asset durations recover with available_range.
This commit is contained in:
@@ -953,6 +953,15 @@ impl ResourceSet {
|
|||||||
};
|
};
|
||||||
let src = external.target_url();
|
let src = external.target_url();
|
||||||
if self.asset_ids.contains_key(src) {
|
if self.asset_ids.contains_key(src) {
|
||||||
|
// Already registered from another track: union the
|
||||||
|
// stream flags so a file used on both a video and an
|
||||||
|
// audio track declares hasVideo AND hasAudio (the
|
||||||
|
// first registration's track kind must not hide the
|
||||||
|
// other stream from the importer).
|
||||||
|
if let Some(existing) = self.assets.iter_mut().find(|a| a.src == src) {
|
||||||
|
existing.has_video |= is_video;
|
||||||
|
existing.has_audio |= is_audio;
|
||||||
|
}
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
let (asset_format_id, asset_duration) = match external.available_range() {
|
let (asset_format_id, asset_duration) = match external.available_range() {
|
||||||
|
|||||||
@@ -472,6 +472,19 @@ pub fn footage_video_params(
|
|||||||
f.video_params(index).map(common_from_node_video)
|
f.video_params(index).map(common_from_node_video)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// The footage's first audio stream's sample rate (`oaknode_footage_get_
|
||||||
|
/// audio_params`), `None` when unprobed or the file has no audio stream.
|
||||||
|
pub fn footage_audio_sample_rate(project: &ProjectRef, footage: NodeId) -> Option<i32> {
|
||||||
|
let guard = lock_project(project);
|
||||||
|
let f = guard
|
||||||
|
.graph
|
||||||
|
.get(footage)?
|
||||||
|
.behavior
|
||||||
|
.as_any()?
|
||||||
|
.downcast_ref::<FootageBehavior>()?;
|
||||||
|
f.audio_params(0).map(|a| a.sample_rate)
|
||||||
|
}
|
||||||
|
|
||||||
/// Overwrite (or create) the footage's `index`th video stream parameters
|
/// Overwrite (or create) the footage's `index`th video stream parameters
|
||||||
/// (`oaknode_footage_set_video_params`).
|
/// (`oaknode_footage_set_video_params`).
|
||||||
pub fn footage_set_video_params(
|
pub fn footage_set_video_params(
|
||||||
|
|||||||
@@ -188,30 +188,42 @@ impl SaveOTIOTask {
|
|||||||
|
|
||||||
let media = nodeops::node_find_input_footage(project, block);
|
let media = nodeops::node_find_input_footage(project, block);
|
||||||
if let Some(media) = media {
|
if let Some(media) = media {
|
||||||
|
// The available range covers the WHOLE media: start
|
||||||
|
// 0, duration = the media length in the reference's
|
||||||
|
// own time base (the video rate for video clips, the
|
||||||
|
// sample rate for audio clips — OTIO's External-
|
||||||
|
// Reference convention). A zero duration declares
|
||||||
|
// the media unusable and DaVinci Resolve rejects
|
||||||
|
// every clip whose source range falls outside it.
|
||||||
|
let duration_seconds = nodeops::node_length(project, media).to_f64();
|
||||||
let available_range = if track_type == TrackType::Video {
|
let available_range = if track_type == TrackType::Video {
|
||||||
// OTIO ExternalReference uses the source clips
|
let source_frame_rate = nodeops::footage_video_params(project, media, 0)
|
||||||
// frame rate (or sample rate) as opposed to the
|
.map(|vp| {
|
||||||
// sequences rate.
|
let (num, den) = vp.frame_rate();
|
||||||
let (source_frame_rate, duration) =
|
if den != 0 {
|
||||||
nodeops::footage_video_params(project, media, 0)
|
num as f64 / den as f64
|
||||||
.map(|vp| {
|
} else {
|
||||||
let (num, den) = vp.frame_rate();
|
0.0
|
||||||
let rate = if den != 0 {
|
}
|
||||||
num as f64 / den as f64
|
})
|
||||||
} else {
|
.filter(|rate| *rate > 0.0)
|
||||||
0.0
|
.unwrap_or(sequence_rate);
|
||||||
};
|
|
||||||
(rate, vp.duration() as f64)
|
|
||||||
})
|
|
||||||
.unwrap_or((0.0, 0.0));
|
|
||||||
TimeRange::new(
|
TimeRange::new(
|
||||||
RationalTime::new(0.0, source_frame_rate),
|
RationalTime::new(0.0, source_frame_rate),
|
||||||
RationalTime::new(duration, source_frame_rate),
|
RationalTime::new(
|
||||||
|
(duration_seconds * source_frame_rate).round(),
|
||||||
|
source_frame_rate,
|
||||||
|
),
|
||||||
)
|
)
|
||||||
} else {
|
} else {
|
||||||
|
let sample_rate = nodeops::footage_audio_sample_rate(project, media)
|
||||||
|
.unwrap_or(48000) as f64;
|
||||||
TimeRange::new(
|
TimeRange::new(
|
||||||
RationalTime::new(0.0, 48000.0),
|
RationalTime::new(0.0, sample_rate),
|
||||||
RationalTime::new(0.0, 48000.0),
|
RationalTime::new(
|
||||||
|
(duration_seconds * sample_rate).round(),
|
||||||
|
sample_rate,
|
||||||
|
),
|
||||||
)
|
)
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -226,9 +238,12 @@ impl SaveOTIOTask {
|
|||||||
Some(Composable::Clip(otio_clip))
|
Some(Composable::Clip(otio_clip))
|
||||||
}
|
}
|
||||||
nodeops::BlockKind::Gap => Some(Composable::Gap(Gap::new(
|
nodeops::BlockKind::Gap => Some(Composable::Gap(Gap::new(
|
||||||
|
// Gaps live in timeline time: the SEQUENCE rate, for both
|
||||||
|
// fields — a TimeRange mixing bases (a 24 fps start with
|
||||||
|
// a 1.0 duration) is unreadable to strict importers.
|
||||||
TimeRange::new(
|
TimeRange::new(
|
||||||
RationalTime::from_rational(block_in_of(project, block), 24.0),
|
RationalTime::from_rational(block_in_of(project, block), sequence_rate),
|
||||||
RationalTime::from_rational(block_length_of(project, block), 24.0),
|
RationalTime::from_rational(block_length_of(project, block), sequence_rate),
|
||||||
),
|
),
|
||||||
nodeops::node_label(project, block),
|
nodeops::node_label(project, block),
|
||||||
))),
|
))),
|
||||||
@@ -256,14 +271,18 @@ impl SaveOTIOTask {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// All OTIO tracks must have the same duration so we add a Gap to
|
// All OTIO tracks must have the same duration so we add a Gap to
|
||||||
// fill the remaining time.
|
// fill the remaining time. Like the gap blocks above, the range
|
||||||
|
// stays in the sequence's time base (duration in whole frames).
|
||||||
let duration = track_duration(&otio_track);
|
let duration = track_duration(&otio_track);
|
||||||
let duration_seconds = duration.clone().to_seconds();
|
let duration_seconds = duration.clone().to_seconds();
|
||||||
if duration_seconds < max_track_length.to_f64() {
|
if duration_seconds < max_track_length.to_f64() {
|
||||||
let time_left = max_track_length.to_f64() - duration_seconds;
|
let time_left = max_track_length.to_f64() - duration_seconds;
|
||||||
|
|
||||||
let gap = Gap::new(
|
let gap = Gap::new(
|
||||||
TimeRange::new(duration, RationalTime::new(time_left, 1.0)),
|
TimeRange::new(
|
||||||
|
RationalTime::new(0.0, sequence_rate),
|
||||||
|
RationalTime::new((time_left * sequence_rate).round(), sequence_rate),
|
||||||
|
),
|
||||||
"",
|
"",
|
||||||
);
|
);
|
||||||
otio_track.append_child(Composable::Gap(gap));
|
otio_track.append_child(Composable::Gap(gap));
|
||||||
@@ -353,6 +372,25 @@ impl TaskBehavior for SaveOTIOTask {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Drop timelines with no clips at all (an empty sequence the user
|
||||||
|
// never touched): DaVinci Resolve's OTIO import expects a single
|
||||||
|
// Timeline root and chokes on a SerializableCollection — skipping
|
||||||
|
// the empty ones lets a lone edited sequence export as a bare
|
||||||
|
// Timeline. FCPXML benefits the same way (no empty <project>s).
|
||||||
|
serialized.retain(|timeline| {
|
||||||
|
timeline.tracks().children().iter().any(|child| {
|
||||||
|
child
|
||||||
|
.as_track()
|
||||||
|
.is_some_and(|track| track.children().iter().any(|b| b.as_clip().is_some()))
|
||||||
|
})
|
||||||
|
});
|
||||||
|
if serialized.is_empty() {
|
||||||
|
task.set_error("Project contains no edited sequences to export.");
|
||||||
|
return Err(Error::Failed(
|
||||||
|
"Project contains no edited sequences to export.".to_string(),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
// Write the serialized timelines, dispatching on the format: OTIO
|
// Write the serialized timelines, dispatching on the format: OTIO
|
||||||
// JSON writes a `Timeline` root for one sequence or a
|
// JSON writes a `Timeline` root for one sequence or a
|
||||||
// `SerializableCollection` for several; FCPXML writes all timelines
|
// `SerializableCollection` for several; FCPXML writes all timelines
|
||||||
|
|||||||
@@ -0,0 +1,305 @@
|
|||||||
|
// Oak Video Editor - Non-Linear Video Editor
|
||||||
|
// Copyright (C) 2026 Oak Team
|
||||||
|
//
|
||||||
|
// This program is free software: you can redistribute it and/or modify
|
||||||
|
// it under the terms of the GNU General Public License as published by
|
||||||
|
// the Free Software Foundation, either version 3 of the License, or
|
||||||
|
// (at your option) any later version.
|
||||||
|
//
|
||||||
|
// This program is distributed in the hope that it will be useful,
|
||||||
|
// but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||||
|
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||||
|
// GNU General Public License for more details.
|
||||||
|
//
|
||||||
|
// You should have received a copy of the GNU General Public License
|
||||||
|
// along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||||
|
|
||||||
|
//! Interchange-export correctness (the "DaVinci Resolve imports nothing"
|
||||||
|
//! report): the OTIO JSON must declare a usable `available_range` for
|
||||||
|
//! every clip (a zero duration rejects the whole file), gap ranges must
|
||||||
|
//! stay in the sequence's time base, an edited lone sequence exports as
|
||||||
|
//! a bare `Timeline` root (empty sequences are skipped), and the FCPXML
|
||||||
|
//! assets of a file used on both video and audio tracks declare both
|
||||||
|
//! stream flags.
|
||||||
|
|
||||||
|
use std::sync::{Arc, Mutex};
|
||||||
|
|
||||||
|
use oak_core::{Rational, TimeRange};
|
||||||
|
use oak_node::block::ClipBlockBehavior;
|
||||||
|
use oak_node::footage::FootageBehavior;
|
||||||
|
use oak_node::id::NodeId;
|
||||||
|
use oak_node::node::NodeCore;
|
||||||
|
use oak_node::project::Project;
|
||||||
|
use oak_node::sequence::SequenceBehavior;
|
||||||
|
use oak_node::track::{TrackBehavior, TrackListBehavior, TrackType};
|
||||||
|
use oak_otio::{MediaReference, Serializable};
|
||||||
|
|
||||||
|
/// The sequence's frame rate (the `set_default_parameters` NTSC
|
||||||
|
/// default) every timeline-range must share.
|
||||||
|
fn sequence_rate() -> f64 {
|
||||||
|
30000.0 / 1001.0
|
||||||
|
}
|
||||||
|
|
||||||
|
fn clip_path(tag: &str, ext: &str) -> std::path::PathBuf {
|
||||||
|
std::env::temp_dir().join(format!("oaktask_otio_{tag}_{}.{ext}", std::process::id()))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Add a track of `kind` carrying one clip of `footage` covering
|
||||||
|
/// `[in, out)` (media seconds) to `list` — the graph plumbing shared by
|
||||||
|
/// the two sequences built below.
|
||||||
|
fn add_track_with_clip(
|
||||||
|
p: &mut Project,
|
||||||
|
footage: NodeId,
|
||||||
|
kind: TrackType,
|
||||||
|
in_: Rational,
|
||||||
|
out: Rational,
|
||||||
|
) -> NodeId {
|
||||||
|
let (tcore, tbehavior) = TrackBehavior::create();
|
||||||
|
let track = p.graph.add_node(tcore, tbehavior);
|
||||||
|
p.graph
|
||||||
|
.get_mut(track)
|
||||||
|
.unwrap()
|
||||||
|
.behavior
|
||||||
|
.as_any_mut()
|
||||||
|
.unwrap()
|
||||||
|
.downcast_mut::<TrackBehavior>()
|
||||||
|
.unwrap()
|
||||||
|
.kind = kind;
|
||||||
|
|
||||||
|
let (ccore, cbehavior) = oak_node::block::clip_create();
|
||||||
|
let clip = p.graph.add_node(ccore, cbehavior);
|
||||||
|
p.graph
|
||||||
|
.connect(footage, clip, oak_node::block::clip_input::TEXTURE_INPUT, -1)
|
||||||
|
.expect("connect footage to clip");
|
||||||
|
p.graph
|
||||||
|
.get_mut(clip)
|
||||||
|
.unwrap()
|
||||||
|
.behavior
|
||||||
|
.as_any_mut()
|
||||||
|
.unwrap()
|
||||||
|
.downcast_mut::<ClipBlockBehavior>()
|
||||||
|
.expect("clip block")
|
||||||
|
.core
|
||||||
|
.range = TimeRange::new(in_, out);
|
||||||
|
p.graph
|
||||||
|
.get_mut(track)
|
||||||
|
.unwrap()
|
||||||
|
.behavior
|
||||||
|
.as_any_mut()
|
||||||
|
.unwrap()
|
||||||
|
.downcast_mut::<TrackBehavior>()
|
||||||
|
.unwrap()
|
||||||
|
.append_block(clip);
|
||||||
|
track
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A project with two sequences: "Edited" (a 1s video clip on one video
|
||||||
|
/// track, a 0.5s video clip on a second — leaving a trailing gap — and
|
||||||
|
/// the same media on an audio track) and "Empty" (no tracks). Both sit
|
||||||
|
/// in the root folder, like the app's project bin.
|
||||||
|
fn build_project(media: &str) -> Arc<Mutex<Project>> {
|
||||||
|
let project = Project::new();
|
||||||
|
{
|
||||||
|
let mut p = project.lock().unwrap();
|
||||||
|
p.initialize().expect("root folder");
|
||||||
|
|
||||||
|
let footage = {
|
||||||
|
let mut f = FootageBehavior::new(media);
|
||||||
|
f.probe().expect("probe the generated clip");
|
||||||
|
p.graph.add_node(NodeCore::new(), Box::new(f))
|
||||||
|
};
|
||||||
|
|
||||||
|
// "Edited": video list (two tracks) + audio list (one track).
|
||||||
|
let (score, sbehavior) = SequenceBehavior::create();
|
||||||
|
let edited = p.graph.add_node(score, sbehavior);
|
||||||
|
{
|
||||||
|
let video_track_a = add_track_with_clip(
|
||||||
|
&mut p,
|
||||||
|
footage,
|
||||||
|
TrackType::Video,
|
||||||
|
Rational::new(0, 1),
|
||||||
|
Rational::new(1, 1),
|
||||||
|
);
|
||||||
|
let video_track_b = add_track_with_clip(
|
||||||
|
&mut p,
|
||||||
|
footage,
|
||||||
|
TrackType::Video,
|
||||||
|
Rational::new(0, 1),
|
||||||
|
Rational::new(1, 2),
|
||||||
|
);
|
||||||
|
let audio_track = add_track_with_clip(
|
||||||
|
&mut p,
|
||||||
|
footage,
|
||||||
|
TrackType::Audio,
|
||||||
|
Rational::new(0, 1),
|
||||||
|
Rational::new(1, 1),
|
||||||
|
);
|
||||||
|
for (kind, tracks) in [
|
||||||
|
(TrackType::Video, vec![video_track_a, video_track_b]),
|
||||||
|
(TrackType::Audio, vec![audio_track]),
|
||||||
|
] {
|
||||||
|
let (lcore, lbehavior) = TrackListBehavior::create();
|
||||||
|
let list = p.graph.add_node(lcore, lbehavior);
|
||||||
|
{
|
||||||
|
let list_b = p
|
||||||
|
.graph
|
||||||
|
.get_mut(list)
|
||||||
|
.unwrap()
|
||||||
|
.behavior
|
||||||
|
.as_any_mut()
|
||||||
|
.unwrap()
|
||||||
|
.downcast_mut::<TrackListBehavior>()
|
||||||
|
.unwrap();
|
||||||
|
list_b.kind = kind;
|
||||||
|
list_b.tracks = tracks;
|
||||||
|
}
|
||||||
|
p.graph
|
||||||
|
.get_mut(edited)
|
||||||
|
.unwrap()
|
||||||
|
.behavior
|
||||||
|
.as_any_mut()
|
||||||
|
.unwrap()
|
||||||
|
.downcast_mut::<SequenceBehavior>()
|
||||||
|
.unwrap()
|
||||||
|
.track_lists
|
||||||
|
.push(list);
|
||||||
|
}
|
||||||
|
p.graph
|
||||||
|
.get_mut(edited)
|
||||||
|
.unwrap()
|
||||||
|
.core
|
||||||
|
.label = "Edited".to_string();
|
||||||
|
}
|
||||||
|
|
||||||
|
// "Empty": a sequence with no track lists at all.
|
||||||
|
let (ecore, ebehavior) = SequenceBehavior::create();
|
||||||
|
let empty = p.graph.add_node(ecore, ebehavior);
|
||||||
|
p.graph.get_mut(empty).unwrap().core.label = "Empty".to_string();
|
||||||
|
|
||||||
|
// Both live in the root folder (the save task enumerates it).
|
||||||
|
let root = p.root;
|
||||||
|
p.graph
|
||||||
|
.get_mut(root)
|
||||||
|
.unwrap()
|
||||||
|
.behavior
|
||||||
|
.as_any_mut()
|
||||||
|
.unwrap()
|
||||||
|
.downcast_mut::<oak_node::folder::FolderBehavior>()
|
||||||
|
.expect("root folder")
|
||||||
|
.children
|
||||||
|
.extend([edited, empty]);
|
||||||
|
}
|
||||||
|
project
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Run the save task for `project` to `filename`.
|
||||||
|
fn save(project: &Arc<Mutex<Project>>, filename: String) {
|
||||||
|
let mut driver = oak_task::task::Task::new("Saving project...", None);
|
||||||
|
driver.set_behavior(Box::new(oak_task::project::saveotio::SaveOTIOTask {
|
||||||
|
base: oak_task::task::Task::new("Saving project...", None),
|
||||||
|
project: project.clone(),
|
||||||
|
filename: filename.clone(),
|
||||||
|
}));
|
||||||
|
if let Err(e) = driver.start() {
|
||||||
|
panic!(
|
||||||
|
"save to {filename} failed: {e:?} / {}",
|
||||||
|
driver.error().unwrap_or("unknown error")
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// OTIO JSON: a lone edited sequence exports as a bare `Timeline` root;
|
||||||
|
/// every clip declares a usable available range; every gap range is in
|
||||||
|
/// the sequence's time base.
|
||||||
|
#[test]
|
||||||
|
fn otio_export_is_importable() {
|
||||||
|
let media = clip_path("src_otio", "mp4");
|
||||||
|
oak_codec::testmedia::write_test_clip(&media, 64, 64, 10, 10).expect("clip generation");
|
||||||
|
let project = build_project(&media.to_string_lossy());
|
||||||
|
let out = clip_path("out", "otio");
|
||||||
|
let _ = std::fs::remove_file(&out);
|
||||||
|
save(&project, out.to_string_lossy().into_owned());
|
||||||
|
|
||||||
|
let doc = oak_otio::from_json_file(&out).expect("the export parses as OTIO");
|
||||||
|
let Serializable::Timeline(timeline) = doc else {
|
||||||
|
panic!("one edited sequence must export as a bare Timeline root, not a collection");
|
||||||
|
};
|
||||||
|
assert_eq!(timeline.name(), "Edited");
|
||||||
|
|
||||||
|
let mut clips = 0;
|
||||||
|
let mut gaps = 0;
|
||||||
|
for child in timeline.tracks().children() {
|
||||||
|
let track = child.as_track().expect("track");
|
||||||
|
for block in track.children() {
|
||||||
|
if let Some(clip) = block.as_clip() {
|
||||||
|
clips += 1;
|
||||||
|
let range = clip
|
||||||
|
.media_reference()
|
||||||
|
.and_then(MediaReference::as_external_reference)
|
||||||
|
.expect("external reference")
|
||||||
|
.available_range()
|
||||||
|
.expect("available_range");
|
||||||
|
assert!(
|
||||||
|
range.duration().value() > 0.0,
|
||||||
|
"a zero available range rejects the file everywhere"
|
||||||
|
);
|
||||||
|
assert!(range.duration().rate() > 0.0);
|
||||||
|
if track.kind() == "Audio" {
|
||||||
|
assert_eq!(
|
||||||
|
range.duration().rate(),
|
||||||
|
48000.0,
|
||||||
|
"an audio clip's available range is in the media's sample rate"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if let Some(gap) = block.as_gap() {
|
||||||
|
gaps += 1;
|
||||||
|
let range = gap.source_range().expect("gap range");
|
||||||
|
assert!(
|
||||||
|
(range.duration().rate() - sequence_rate()).abs() < 1e-6,
|
||||||
|
"gap duration rate {} is not the sequence rate",
|
||||||
|
range.duration().rate()
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
(range.start_time().rate() - sequence_rate()).abs() < 1e-6,
|
||||||
|
"gap start rate {} is not the sequence rate",
|
||||||
|
range.start_time().rate()
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
assert_eq!(clips, 3, "two video clips + one audio clip");
|
||||||
|
assert!(gaps >= 1, "the half-length video track gains a trailing gap");
|
||||||
|
|
||||||
|
let _ = std::fs::remove_file(&media);
|
||||||
|
let _ = std::fs::remove_file(&out);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// FCPXML: the shared media's single asset declares hasVideo AND
|
||||||
|
/// hasAudio, and no asset carries a zero duration.
|
||||||
|
#[test]
|
||||||
|
fn fcpxml_export_declares_both_streams_and_real_durations() {
|
||||||
|
let media = clip_path("src_fcpxml", "mp4");
|
||||||
|
oak_codec::testmedia::write_test_clip(&media, 64, 64, 10, 10).expect("clip generation");
|
||||||
|
let project = build_project(&media.to_string_lossy());
|
||||||
|
let out = clip_path("out", "fcpxml");
|
||||||
|
let _ = std::fs::remove_file(&out);
|
||||||
|
save(&project, out.to_string_lossy().into_owned());
|
||||||
|
|
||||||
|
let xml = std::fs::read_to_string(&out).expect("read the fcpxml export");
|
||||||
|
assert!(
|
||||||
|
xml.contains("hasVideo=\"1\""),
|
||||||
|
"the shared media's asset declares video: {xml}"
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
xml.contains("hasAudio=\"1\""),
|
||||||
|
"the shared media's asset declares audio: {xml}"
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
!xml.contains("duration=\"0/1s\""),
|
||||||
|
"no asset carries a zero duration: {xml}"
|
||||||
|
);
|
||||||
|
|
||||||
|
let _ = std::fs::remove_file(&media);
|
||||||
|
let _ = std::fs::remove_file(&out);
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user