test(oak-task, oak-storage): task managers, codec bridge, storage
Task/manager lifecycles, precache and render boundaries, OTIO/FCPXML round trips, and the write-through/library contract tests.
This commit is contained in:
@@ -1181,4 +1181,573 @@ pub fn add_track_command(project: ProjectRef, list: NodeId) -> UndoCommand {
|
||||
kind: TrackType::Video,
|
||||
})
|
||||
}
|
||||
kind: TrackType::Video,
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use oak_node::track::TrackType;
|
||||
use oak_node::value::AudioParams;
|
||||
|
||||
// ---- fixtures -------------------------------------------------------
|
||||
|
||||
fn project() -> ProjectRef {
|
||||
Project::new()
|
||||
}
|
||||
|
||||
fn sequence_with_list(project: &ProjectRef, kind: TrackType) -> (NodeId, NodeId) {
|
||||
let seq = sequence_create(project).expect("sequence");
|
||||
let list = sequence_track_list(project, seq, kind).expect("track list");
|
||||
(seq, list)
|
||||
}
|
||||
|
||||
/// Append a track through the public command (the only path that wires
|
||||
/// the `index`/`track_list` fields), returning its id.
|
||||
fn append_track(project: &ProjectRef, list: NodeId) -> NodeId {
|
||||
let mut cmd = add_track_command(project.clone(), list);
|
||||
cmd.redo_now();
|
||||
let guard = lock_project(project);
|
||||
guard
|
||||
.graph
|
||||
.get(list)
|
||||
.and_then(|e| e.behavior.as_any())
|
||||
.and_then(|a| a.downcast_ref::<TrackListBehavior>())
|
||||
.and_then(|l| l.tracks.last().copied())
|
||||
.expect("track appended")
|
||||
}
|
||||
|
||||
fn add_block(project: &ProjectRef, kind: BlockKind) -> NodeId {
|
||||
block_create(project, kind).expect("block created")
|
||||
}
|
||||
|
||||
fn add_adjustment(project: &ProjectRef) -> NodeId {
|
||||
let (core, behavior) = oak_node::block::adjustment_create();
|
||||
lock_project(project).graph.add_node(core, behavior)
|
||||
}
|
||||
|
||||
/// Give a block an exact timeline range (the public setters keep the
|
||||
/// out-point anchored and shift the in-point, which is awkward for
|
||||
/// length assertions).
|
||||
fn set_block_range(project: &ProjectRef, block: NodeId, in_: Rational, out: Rational) {
|
||||
let mut guard = lock_project(project);
|
||||
let ok = set_block_core(&mut guard.graph, block, |core| {
|
||||
core.range = oak_core::TimeRange::new(in_, out);
|
||||
});
|
||||
assert!(ok, "block {block:?} range set");
|
||||
}
|
||||
|
||||
fn with_graph<R>(project: &ProjectRef, f: impl FnOnce(&Graph) -> R) -> R {
|
||||
let guard = lock_project(project);
|
||||
f(&guard.graph)
|
||||
}
|
||||
|
||||
fn folder_children(project: &ProjectRef, folder: NodeId) -> Vec<NodeId> {
|
||||
with_graph(project, |g| {
|
||||
g.get(folder)
|
||||
.and_then(|e| e.behavior.as_any())
|
||||
.and_then(|a| a.downcast_ref::<FolderBehavior>())
|
||||
.map(|f| f.children.clone())
|
||||
.unwrap_or_default()
|
||||
})
|
||||
}
|
||||
|
||||
fn bin_folder_of(project: &ProjectRef, node: NodeId) -> Option<NodeId> {
|
||||
with_graph(project, |g| g.get(node).and_then(|e| e.core.bin_folder))
|
||||
}
|
||||
|
||||
fn write_clip(tag: &str) -> std::path::PathBuf {
|
||||
let path = std::env::temp_dir().join(format!(
|
||||
"oaktask_nodeops_{tag}_{}.mp4",
|
||||
std::process::id()
|
||||
));
|
||||
oak_codec::testmedia::write_test_clip(&path, 64, 64, 10, 10).expect("test clip");
|
||||
path
|
||||
}
|
||||
|
||||
// ---- shared block core ---------------------------------------------
|
||||
|
||||
#[test]
|
||||
fn block_core_of_covers_every_block_kind_and_rejects_non_blocks() {
|
||||
let p = project();
|
||||
let clip = add_block(&p, BlockKind::Clip);
|
||||
let gap = add_block(&p, BlockKind::Gap);
|
||||
let transition = add_block(&p, BlockKind::Transition);
|
||||
let adjustment = add_adjustment(&p);
|
||||
let folder = folder_create(&p).expect("folder");
|
||||
let sequence = sequence_create(&p).expect("sequence");
|
||||
|
||||
with_graph(&p, |g| {
|
||||
for id in [clip, gap, transition, adjustment] {
|
||||
assert!(block_core_of(g, id).is_some(), "block {id:?} must have a core");
|
||||
}
|
||||
assert!(block_core_of(g, folder).is_none(), "folder is not a block");
|
||||
assert!(block_core_of(g, sequence).is_none(), "sequence is not a block");
|
||||
assert!(block_core_of(g, NodeId::INVALID).is_none(), "stale id");
|
||||
});
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn block_create_rejects_the_unknown_kind() {
|
||||
let p = project();
|
||||
assert!(block_create(&p, BlockKind::Other).is_none());
|
||||
assert!(block_create(&p, BlockKind::Clip).is_some());
|
||||
assert!(block_create(&p, BlockKind::Gap).is_some());
|
||||
assert!(block_create(&p, BlockKind::Transition).is_some());
|
||||
}
|
||||
|
||||
// ---- node metadata --------------------------------------------------
|
||||
|
||||
#[test]
|
||||
fn labels_round_trip_and_missing_nodes_stay_empty() {
|
||||
let p = project();
|
||||
let seq = sequence_create(&p).expect("sequence");
|
||||
assert_eq!(node_label(&p, seq), "");
|
||||
set_node_label(&p, seq, "Timeline 1");
|
||||
assert_eq!(node_label(&p, seq), "Timeline 1");
|
||||
|
||||
// Missing / invalid nodes are safe no-ops.
|
||||
set_node_label(&p, NodeId::INVALID, "nope");
|
||||
assert_eq!(node_label(&p, NodeId::INVALID), "");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn node_type_ids_and_predicates_match_the_node_kind() {
|
||||
let p = project();
|
||||
let footage = footage_create(&p, None).expect("footage");
|
||||
let sequence = sequence_create(&p).expect("sequence");
|
||||
let folder = folder_create(&p).expect("folder");
|
||||
|
||||
assert_eq!(node_type_id(&p, sequence), SEQUENCE_TYPE_ID);
|
||||
assert_eq!(node_type_id(&p, NodeId::INVALID), "");
|
||||
|
||||
with_graph(&p, |g| {
|
||||
assert!(node_is_footage(g, footage));
|
||||
assert!(!node_is_footage(g, sequence));
|
||||
assert!(!node_is_footage(g, folder));
|
||||
assert!(node_is_sequence(g, sequence));
|
||||
assert!(!node_is_sequence(g, footage));
|
||||
assert!(!node_is_sequence(g, NodeId::INVALID));
|
||||
});
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn context_position_is_a_safe_no_op_for_missing_nodes() {
|
||||
let p = project();
|
||||
let seq = sequence_create(&p).expect("sequence");
|
||||
node_set_context_position(&p, seq, NodeId::INVALID, 1.0, 2.0, true);
|
||||
node_set_context_position(&p, NodeId::INVALID, seq, 1.0, 2.0, false);
|
||||
}
|
||||
|
||||
// ---- sequence / track list -----------------------------------------
|
||||
|
||||
#[test]
|
||||
fn sequence_track_list_creates_once_per_kind_and_rejects_non_sequences() {
|
||||
let p = project();
|
||||
let seq = sequence_create(&p).expect("sequence");
|
||||
let video = sequence_track_list(&p, seq, TrackType::Video).expect("video list");
|
||||
let audio = sequence_track_list(&p, seq, TrackType::Audio).expect("audio list");
|
||||
let subtitle = sequence_track_list(&p, seq, TrackType::Subtitle).expect("subtitle list");
|
||||
assert_ne!(video, audio);
|
||||
assert_ne!(video, subtitle);
|
||||
// Created once: the sequence caches each list.
|
||||
assert_eq!(sequence_track_list(&p, seq, TrackType::Video), Some(video));
|
||||
assert_eq!(sequence_track_list(&p, seq, TrackType::Audio), Some(audio));
|
||||
assert_eq!(sequence_track_list(&p, seq, TrackType::Subtitle), Some(subtitle));
|
||||
|
||||
// Non-sequences and stale ids have no track lists.
|
||||
let folder = folder_create(&p).expect("folder");
|
||||
assert_eq!(sequence_track_list(&p, folder, TrackType::Video), None);
|
||||
assert_eq!(sequence_track_list(&p, NodeId::INVALID, TrackType::Video), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sequence_params_default_and_read_back() {
|
||||
let p = project();
|
||||
let seq = sequence_create(&p).expect("sequence");
|
||||
// The domain constructor applies the default parameters.
|
||||
let default = sequence_video_params(&p, seq, 0).expect("default video params");
|
||||
assert_eq!((default.width(), default.height()), (1920, 1080));
|
||||
assert_eq!(default.channel_count(), 4);
|
||||
assert_eq!(sequence_audio_params(&p, seq), (48000, 0x3));
|
||||
assert!(sequence_video_params(&p, seq, 1).is_none(), "out of range");
|
||||
assert_eq!(sequence_audio_params(&p, NodeId::INVALID), (48000, 0x3));
|
||||
assert!(sequence_video_params(&p, NodeId::INVALID, 0).is_none());
|
||||
assert!(sequence_video_params(&p, folder_create(&p).expect("folder"), 0).is_none());
|
||||
|
||||
// Replace the defaults and read them back through the oak_core type.
|
||||
{
|
||||
let mut guard = lock_project(&p);
|
||||
let entry = guard.graph.get_mut(seq).expect("sequence");
|
||||
let s = entry
|
||||
.behavior
|
||||
.as_any_mut()
|
||||
.and_then(|a| a.downcast_mut::<SequenceBehavior>())
|
||||
.expect("sequence behavior");
|
||||
s.video_params = vec![oak_node::value::VideoParams {
|
||||
width: 1280,
|
||||
height: 720,
|
||||
frame_rate: Rational::new(24, 1),
|
||||
pixel_format: oak_core::ocioutils::PixelFormat::F32.code(),
|
||||
channels: 4,
|
||||
interlaced: true,
|
||||
}];
|
||||
s.audio_params = vec![AudioParams {
|
||||
sample_rate: 44100,
|
||||
channel_layout: 0x3,
|
||||
format: 0,
|
||||
}];
|
||||
}
|
||||
let v = sequence_video_params(&p, seq, 0).expect("video params");
|
||||
assert_eq!((v.width(), v.height()), (1280, 720));
|
||||
assert_eq!(v.frame_rate(), (24, 1));
|
||||
assert_eq!(v.channel_count(), 4);
|
||||
assert_eq!(sequence_audio_params(&p, seq), (44100, 0x3));
|
||||
}
|
||||
|
||||
// ---- footage --------------------------------------------------------
|
||||
|
||||
#[test]
|
||||
fn footage_accessors_reject_wrong_node_types() {
|
||||
let p = project();
|
||||
let folder = folder_create(&p).expect("folder");
|
||||
assert!(!footage_set_filename(&p, folder, "x.mp4"));
|
||||
assert!(!footage_is_valid(&p, folder));
|
||||
assert_eq!(footage_filename(&p, folder), "");
|
||||
assert_eq!(footage_total_stream_count(&p, folder), 0);
|
||||
assert!(footage_video_params(&p, folder, 0).is_none());
|
||||
assert_eq!(footage_audio_sample_rate(&p, folder), None);
|
||||
assert_eq!(footage_filename(&p, NodeId::INVALID), "");
|
||||
assert!(!footage_is_valid(&p, NodeId::INVALID));
|
||||
// Cancellation on a wrong node is a safe no-op.
|
||||
footage_set_cancelled(&p, folder, true);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn footage_probe_round_trip_with_real_media() {
|
||||
let path = write_clip("probe");
|
||||
let p = project();
|
||||
let footage = footage_create(&p, None).expect("footage");
|
||||
assert!(!footage_is_valid(&p, footage));
|
||||
assert_eq!(footage_total_stream_count(&p, footage), 0);
|
||||
|
||||
// A missing file fails the probe without panicking.
|
||||
assert!(!footage_set_filename(&p, footage, "/definitely/not/here.mp4"));
|
||||
assert!(!footage_is_valid(&p, footage));
|
||||
|
||||
// A real clip probes: valid, with stream parameters.
|
||||
assert!(footage_set_filename(&p, footage, &path.to_string_lossy()));
|
||||
assert!(footage_is_valid(&p, footage));
|
||||
assert_eq!(footage_filename(&p, footage), path.to_string_lossy());
|
||||
assert!(footage_total_stream_count(&p, footage) >= 1);
|
||||
let video = footage_video_params(&p, footage, 0).expect("video params");
|
||||
assert_eq!((video.width(), video.height()), (64, 64));
|
||||
assert!(footage_audio_sample_rate(&p, footage).is_some());
|
||||
assert!(node_length(&p, footage) > Rational::new(0, 1));
|
||||
|
||||
// Out-of-range stream index.
|
||||
assert!(footage_video_params(&p, footage, 99).is_none());
|
||||
|
||||
// Overwriting a stream parameter round-trips through the oak_core type.
|
||||
let mut params = CommonVideoParams::new_basic(
|
||||
32,
|
||||
32,
|
||||
oak_core::ocioutils::PixelFormat::F32,
|
||||
4,
|
||||
1,
|
||||
1,
|
||||
0,
|
||||
1,
|
||||
);
|
||||
params.set_frame_rate(30, 1);
|
||||
footage_set_video_params(&p, footage, 0, ¶ms);
|
||||
let back = footage_video_params(&p, footage, 0).expect("overwritten params");
|
||||
assert_eq!((back.width(), back.height()), (32, 32));
|
||||
assert_eq!(back.frame_rate(), (30, 1));
|
||||
// A wrong node type is a no-op.
|
||||
footage_set_video_params(&p, NodeId::INVALID, 0, ¶ms);
|
||||
|
||||
let _ = std::fs::remove_file(&path);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn node_length_covers_footage_sequence_and_other_types() {
|
||||
let path = write_clip("length");
|
||||
let p = project();
|
||||
let footage = footage_create(&p, None).expect("footage");
|
||||
assert!(footage_set_filename(&p, footage, &path.to_string_lossy()));
|
||||
assert!(node_length(&p, footage) > Rational::new(0, 1));
|
||||
|
||||
let (seq, list) = sequence_with_list(&p, TrackType::Video);
|
||||
assert_eq!(node_length(&p, seq), Rational::new(0, 1), "empty sequence");
|
||||
let track = append_track(&p, list);
|
||||
let a = add_block(&p, BlockKind::Clip);
|
||||
let b = add_block(&p, BlockKind::Clip);
|
||||
assert!(track_append_block(&p, track, a));
|
||||
assert!(track_append_block(&p, track, b));
|
||||
set_block_range(&p, a, Rational::new(0, 1), Rational::new(2, 1));
|
||||
set_block_range(&p, b, Rational::new(2, 1), Rational::new(5, 1));
|
||||
assert_eq!(node_length(&p, seq), Rational::new(5, 1), "longest track out");
|
||||
|
||||
let folder = folder_create(&p).expect("folder");
|
||||
assert_eq!(node_length(&p, folder), Rational::new(0, 1));
|
||||
assert_eq!(node_length(&p, NodeId::INVALID), Rational::new(0, 1));
|
||||
|
||||
let _ = std::fs::remove_file(&path);
|
||||
}
|
||||
|
||||
// ---- tracks and blocks ---------------------------------------------
|
||||
|
||||
#[test]
|
||||
fn tracklist_accessors_and_out_of_range() {
|
||||
let p = project();
|
||||
let (_, list) = sequence_with_list(&p, TrackType::Audio);
|
||||
assert_eq!(tracklist_track_count(&p, list), 0);
|
||||
assert!(tracklist_track_at(&p, list, 0).is_none());
|
||||
let track = append_track(&p, list);
|
||||
assert_eq!(tracklist_track_count(&p, list), 1);
|
||||
assert_eq!(tracklist_track_at(&p, list, 0), Some(track));
|
||||
assert!(tracklist_track_at(&p, list, 1).is_none());
|
||||
assert_eq!(track_type(&p, track), Some(TrackType::Audio));
|
||||
assert_eq!(track_type(&p, list), None);
|
||||
assert_eq!(track_type(&p, NodeId::INVALID), None);
|
||||
// A non-list node has no tracks.
|
||||
assert_eq!(tracklist_track_count(&p, track), 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn track_blocks_append_guard_and_length() {
|
||||
let p = project();
|
||||
let (_, list) = sequence_with_list(&p, TrackType::Video);
|
||||
let track = append_track(&p, list);
|
||||
let clip = add_block(&p, BlockKind::Clip);
|
||||
assert!(!track_append_block(&p, NodeId::INVALID, clip), "stale track");
|
||||
assert!(track_append_block(&p, track, clip));
|
||||
assert_eq!(track_block_count(&p, track), 1);
|
||||
assert_eq!(track_block_at(&p, track, 0), Some(clip));
|
||||
assert!(track_block_at(&p, track, 1).is_none());
|
||||
assert_eq!(track_block_count(&p, NodeId::INVALID), 0);
|
||||
set_block_range(&p, clip, Rational::new(0, 1), Rational::new(0, 1));
|
||||
assert_eq!(track_length(&p, track), Rational::new(0, 1), "zero-length block");
|
||||
set_block_range(&p, clip, Rational::new(1, 1), Rational::new(4, 1));
|
||||
assert_eq!(track_length(&p, track), Rational::new(4, 1), "max out");
|
||||
assert_eq!(track_length(&p, NodeId::INVALID), Rational::new(0, 1));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn block_getters_default_for_missing_nodes_and_setters_round_trip() {
|
||||
let p = project();
|
||||
let clip = add_block(&p, BlockKind::Clip);
|
||||
assert_eq!(block_in(&p, NodeId::INVALID), Rational::new(0, 1));
|
||||
assert_eq!(block_length(&p, NodeId::INVALID), Rational::new(0, 1));
|
||||
assert_eq!(block_media_in(&p, NodeId::INVALID), Rational::new(0, 1));
|
||||
|
||||
clip_set_media_in(&p, clip, 2, 1);
|
||||
assert_eq!(block_media_in(&p, clip), Rational::new(2, 1));
|
||||
block_set_length_and_media_out(&p, clip, 5, 1);
|
||||
assert_eq!(block_length(&p, clip), Rational::new(5, 1));
|
||||
// Setters on a non-block/missing node are no-ops.
|
||||
block_set_length_and_media_out(&p, NodeId::INVALID, 1, 1);
|
||||
clip_set_media_in(&p, NodeId::INVALID, 9, 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn clip_footage_round_trip_and_non_clip_nodes() {
|
||||
let p = project();
|
||||
let clip = add_block(&p, BlockKind::Clip);
|
||||
let gap = add_block(&p, BlockKind::Gap);
|
||||
let footage = footage_create(&p, None).expect("footage");
|
||||
assert_eq!(clip_footage(&p, clip), None);
|
||||
clip_set_footage(&p, clip, footage);
|
||||
assert_eq!(clip_footage(&p, clip), Some(footage));
|
||||
assert_eq!(clip_footage(&p, gap), None);
|
||||
assert_eq!(clip_footage(&p, NodeId::INVALID), None);
|
||||
clip_set_footage(&p, gap, footage); // no-op on a gap
|
||||
assert_eq!(clip_footage(&p, gap), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn transition_offsets_default_and_set_length() {
|
||||
let p = project();
|
||||
let transition = add_block(&p, BlockKind::Transition);
|
||||
assert_eq!(transition_in_offset(&p, transition), Rational::new(0, 1));
|
||||
assert_eq!(transition_out_offset(&p, transition), Rational::new(0, 1));
|
||||
assert_eq!(transition_in_offset(&p, NodeId::INVALID), Rational::new(0, 1));
|
||||
|
||||
transition_set_offsets_and_length(&p, transition, 1, 2, 3, 4);
|
||||
assert_eq!(transition_in_offset(&p, transition), Rational::new(1, 2));
|
||||
assert_eq!(transition_out_offset(&p, transition), Rational::new(3, 4));
|
||||
assert_eq!(
|
||||
block_length(&p, transition),
|
||||
Rational::new(5, 4),
|
||||
"length = in + out"
|
||||
);
|
||||
// Wrong node types are no-ops.
|
||||
transition_set_offsets_and_length(&p, NodeId::INVALID, 1, 1, 1, 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn block_kind_and_adjustment_predicates() {
|
||||
let p = project();
|
||||
let adjustment = add_adjustment(&p);
|
||||
assert!(block_is_adjustment(&p, adjustment));
|
||||
assert!(!block_is_adjustment(&p, NodeId::INVALID));
|
||||
for (kind, expected) in [
|
||||
(BlockKind::Clip, BlockKind::Clip),
|
||||
(BlockKind::Gap, BlockKind::Gap),
|
||||
(BlockKind::Transition, BlockKind::Transition),
|
||||
] {
|
||||
let block = add_block(&p, kind);
|
||||
assert_eq!(block_kind(&p, block), expected);
|
||||
}
|
||||
assert_eq!(block_kind(&p, adjustment), BlockKind::Other);
|
||||
assert_eq!(block_kind(&p, NodeId::INVALID), BlockKind::Other);
|
||||
// An adjustment block is not one of the C-ABI block kinds.
|
||||
let folder = folder_create(&p).expect("folder");
|
||||
assert_eq!(block_kind(&p, folder), BlockKind::Other);
|
||||
}
|
||||
|
||||
// ---- graph adapters and connections --------------------------------
|
||||
|
||||
#[test]
|
||||
fn graph_range_adapters_default_for_missing_nodes() {
|
||||
let p = project();
|
||||
let (_, list) = sequence_with_list(&p, TrackType::Video);
|
||||
let track = append_track(&p, list);
|
||||
let clip = add_block(&p, BlockKind::Clip);
|
||||
assert!(track_append_block(&p, track, clip));
|
||||
set_block_range(&p, clip, Rational::new(2, 1), Rational::new(6, 1));
|
||||
|
||||
with_graph(&p, |g| {
|
||||
let blocks = GraphBlockRange { graph: g };
|
||||
assert_eq!(blocks.in_(NodeId::INVALID), Rational::new(0, 1));
|
||||
assert_eq!(blocks.out(NodeId::INVALID), Rational::new(0, 1));
|
||||
assert_eq!(blocks.in_(clip), Rational::new(2, 1));
|
||||
assert_eq!(blocks.out(clip), Rational::new(6, 1));
|
||||
|
||||
let tracks = GraphTrackRange { graph: g };
|
||||
assert_eq!(tracks.length(NodeId::INVALID), Rational::new(0, 1));
|
||||
assert_eq!(tracks.length(clip), Rational::new(0, 1), "not a track");
|
||||
assert_eq!(tracks.length(track), Rational::new(6, 1));
|
||||
});
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn node_connect_validates_endpoints_and_inputs() {
|
||||
let p = project();
|
||||
let footage = footage_create(&p, None).expect("footage");
|
||||
let clip = add_block(&p, BlockKind::Clip);
|
||||
assert!(node_connect(&p, footage, clip, CLIP_TEXTURE_INPUT));
|
||||
assert!(!node_connect(&p, clip, footage, CLIP_TEXTURE_INPUT), "unknown input");
|
||||
assert!(!node_connect(&p, NodeId::INVALID, clip, CLIP_TEXTURE_INPUT));
|
||||
assert!(!node_connect(&p, footage, NodeId::INVALID, CLIP_TEXTURE_INPUT));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn find_input_footage_direct_and_through_the_graph() {
|
||||
let p = project();
|
||||
let footage = footage_create(&p, None).expect("footage");
|
||||
let clip = add_block(&p, BlockKind::Clip);
|
||||
let folder = folder_create(&p).expect("folder");
|
||||
|
||||
// A footage node answers with itself.
|
||||
with_graph(&p, |g| assert_eq!(find_input_footage(g, footage), Some(footage)));
|
||||
// A clip answers with its recorded footage...
|
||||
clip_set_footage(&p, clip, footage);
|
||||
with_graph(&p, |g| assert_eq!(find_input_footage(g, clip), Some(footage)));
|
||||
// ...but a stale recorded footage is ignored (falls through to the
|
||||
// graph walk, which finds nothing here).
|
||||
clip_set_footage(&p, clip, NodeId::INVALID);
|
||||
with_graph(&p, |g| assert_eq!(find_input_footage(g, clip), None));
|
||||
assert_eq!(node_find_input_footage(&p, clip), None);
|
||||
// A graph edge to a footage node is found by the walk.
|
||||
assert!(node_connect(&p, footage, clip, CLIP_TEXTURE_INPUT));
|
||||
assert_eq!(node_find_input_footage(&p, clip), Some(footage));
|
||||
// Non-footage, unconnected nodes have no footage.
|
||||
assert_eq!(node_find_input_footage(&p, folder), None);
|
||||
assert_eq!(node_find_input_footage(&p, NodeId::INVALID), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pixel_format_codes_map_known_and_unknown() {
|
||||
use oak_core::PixelFormat as P;
|
||||
assert_eq!(pixel_format_from_code(-1), P::Invalid);
|
||||
assert_eq!(pixel_format_from_code(0), P::U8);
|
||||
assert_eq!(pixel_format_from_code(1), P::U10);
|
||||
assert_eq!(pixel_format_from_code(2), P::U16);
|
||||
assert_eq!(pixel_format_from_code(3), P::F16);
|
||||
assert_eq!(pixel_format_from_code(4), P::F32);
|
||||
assert_eq!(pixel_format_from_code(99), P::Invalid);
|
||||
}
|
||||
|
||||
// ---- undo commands --------------------------------------------------
|
||||
|
||||
#[test]
|
||||
fn folder_add_child_command_redo_undo() {
|
||||
let p = project();
|
||||
let folder = folder_create(&p).expect("folder");
|
||||
let child = footage_create(&p, None).expect("footage");
|
||||
let mut cmd =
|
||||
folder_add_child_command((p.clone(), folder), (p.clone(), child));
|
||||
|
||||
assert!(folder_children(&p, folder).is_empty());
|
||||
cmd.redo_now();
|
||||
assert_eq!(folder_children(&p, folder), vec![child]);
|
||||
assert_eq!(bin_folder_of(&p, child), Some(folder));
|
||||
|
||||
cmd.undo_now();
|
||||
assert!(folder_children(&p, folder).is_empty());
|
||||
assert_eq!(bin_folder_of(&p, child), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn remove_node_command_restores_the_entry_and_folder_slot() {
|
||||
let p = project();
|
||||
let folder = folder_create(&p).expect("folder");
|
||||
let first = footage_create(&p, None).expect("first");
|
||||
let second = footage_create(&p, None).expect("second");
|
||||
folder_add_child_command((p.clone(), folder), (p.clone(), first)).redo_now();
|
||||
folder_add_child_command((p.clone(), folder), (p.clone(), second)).redo_now();
|
||||
assert_eq!(folder_children(&p, folder), vec![first, second]);
|
||||
|
||||
let mut cmd = remove_node_command(p.clone(), first);
|
||||
cmd.redo_now();
|
||||
assert!(with_graph(&p, |g| g.get(first).is_none()), "removed from graph");
|
||||
assert_eq!(folder_children(&p, folder), vec![second]);
|
||||
|
||||
cmd.undo_now();
|
||||
assert!(with_graph(&p, |g| g.get(first).is_some()), "restored");
|
||||
assert_eq!(folder_children(&p, folder), vec![first, second], "slot restored");
|
||||
assert_eq!(bin_folder_of(&p, first), Some(folder));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn remove_node_command_handles_nodes_without_a_folder() {
|
||||
let p = project();
|
||||
let node = folder_create(&p).expect("folder");
|
||||
let mut cmd = remove_node_command(p.clone(), node);
|
||||
cmd.redo_now();
|
||||
assert!(with_graph(&p, |g| g.get(node).is_none()));
|
||||
cmd.undo_now();
|
||||
assert!(with_graph(&p, |g| g.get(node).is_some()));
|
||||
assert_eq!(bin_folder_of(&p, node), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn add_track_command_appends_and_removes() {
|
||||
let p = project();
|
||||
let (_, list) = sequence_with_list(&p, TrackType::Subtitle);
|
||||
assert_eq!(tracklist_track_count(&p, list), 0);
|
||||
let mut cmd = add_track_command(p.clone(), list);
|
||||
cmd.redo_now();
|
||||
assert_eq!(tracklist_track_count(&p, list), 1);
|
||||
let track = tracklist_track_at(&p, list, 0).expect("track");
|
||||
assert_eq!(track_type(&p, track), Some(TrackType::Subtitle));
|
||||
cmd.undo_now();
|
||||
assert_eq!(tracklist_track_count(&p, list), 0);
|
||||
assert!(with_graph(&p, |g| g.get(track).is_none()), "track removed");
|
||||
|
||||
// A stale list makes redo a no-op instead of a panic.
|
||||
let mut missing = add_track_command(p.clone(), NodeId::INVALID);
|
||||
missing.redo_now();
|
||||
missing.undo_now();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1241,4 +1241,709 @@ fn shm_frame_to_texture(frame: &ShmFrameRef) -> oak_core::texture::Texture {
|
||||
.collect();
|
||||
oak_core::texture::Texture::wrap_frame(f)
|
||||
}
|
||||
.collect();
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
use oak_core::ocioutils::PixelFormat as OakPixelFormat;
|
||||
use oak_core::PixelFormat;
|
||||
use oak_node::block::ClipBlockBehavior;
|
||||
use oak_node::folder;
|
||||
use oak_node::id::NodeId;
|
||||
use oak_node::node::NodeCore;
|
||||
use oak_node::sequence::SequenceBehavior;
|
||||
use oak_node::track::{TrackBehavior, TrackListBehavior, TrackType};
|
||||
use oak_node::value::NodeValue;
|
||||
use oak_render::ticket::AudioSamples;
|
||||
|
||||
/// A fresh project (the same `Arc<Mutex<Project>>` shape the integration
|
||||
/// fixtures build, but without any media I/O — these tests only touch
|
||||
/// the pure graph-to-params marshalling).
|
||||
fn project() -> ProjectRef {
|
||||
oak_node::project::Project::new()
|
||||
}
|
||||
|
||||
fn add_footage(project: &ProjectRef, filename: &str) -> NodeId {
|
||||
let (core, behavior) = oak_node::footage::FootageBehavior::create();
|
||||
let id = project.lock().unwrap().graph.add_node(core, behavior);
|
||||
{
|
||||
let mut guard = project.lock().unwrap();
|
||||
if let Some(f) = guard
|
||||
.graph
|
||||
.get_mut(id)
|
||||
.and_then(|e| e.behavior.as_any_mut())
|
||||
.and_then(|a| a.downcast_mut::<oak_node::footage::FootageBehavior>())
|
||||
{
|
||||
f.filename = filename.to_string();
|
||||
}
|
||||
}
|
||||
id
|
||||
}
|
||||
|
||||
fn add_folder(project: &ProjectRef) -> NodeId {
|
||||
let (core, behavior) = folder::create("");
|
||||
project.lock().unwrap().graph.add_node(core, behavior)
|
||||
}
|
||||
|
||||
fn add_sequence(project: &ProjectRef) -> NodeId {
|
||||
let (core, behavior) = SequenceBehavior::create();
|
||||
project.lock().unwrap().graph.add_node(core, behavior)
|
||||
}
|
||||
|
||||
fn add_clip(project: &ProjectRef, footage: Option<NodeId>, range: TimeRange) -> NodeId {
|
||||
let (core, behavior) = oak_node::block::clip_create();
|
||||
let clip = project.lock().unwrap().graph.add_node(core, behavior);
|
||||
let mut guard = project.lock().unwrap();
|
||||
if let Some(c) = guard
|
||||
.graph
|
||||
.get_mut(clip)
|
||||
.and_then(|e| e.behavior.as_any_mut())
|
||||
.and_then(|a| a.downcast_mut::<ClipBlockBehavior>())
|
||||
{
|
||||
c.core.range = range;
|
||||
c.footage = footage;
|
||||
}
|
||||
clip
|
||||
}
|
||||
|
||||
// ---- pure accessors -------------------------------------------------
|
||||
|
||||
#[test]
|
||||
fn force_size_and_format_cover_off_and_on_paths() {
|
||||
let project = project();
|
||||
let make = |force: ForceParams| {
|
||||
RenderTask::new(
|
||||
Task::new("t", None),
|
||||
None,
|
||||
(project.clone(), NodeId::INVALID),
|
||||
force,
|
||||
None,
|
||||
)
|
||||
};
|
||||
|
||||
// All-off defaults: no forced size, no forced format.
|
||||
let task = make(ForceParams::default());
|
||||
assert_eq!(task.force_size(), None);
|
||||
assert_eq!(task.force_format(), None);
|
||||
|
||||
// A single positive dimension is not enough for a forced size.
|
||||
let task = make(ForceParams {
|
||||
force_width: 32,
|
||||
..ForceParams::default()
|
||||
});
|
||||
assert_eq!(task.force_size(), None);
|
||||
let task = make(ForceParams {
|
||||
force_width: 0,
|
||||
force_height: 32,
|
||||
..ForceParams::default()
|
||||
});
|
||||
assert_eq!(task.force_size(), None);
|
||||
|
||||
// Both positive: forced.
|
||||
let task = make(ForceParams {
|
||||
force_width: 32,
|
||||
force_height: 16,
|
||||
..ForceParams::default()
|
||||
});
|
||||
assert_eq!(task.force_size(), Some((32, 16)));
|
||||
|
||||
// Format codes map through `pixel_format_from_code`: -1 is off,
|
||||
// known codes map, unknown codes become Invalid.
|
||||
let task = make(ForceParams {
|
||||
force_format: PixelFormat::F32 as i32,
|
||||
..ForceParams::default()
|
||||
});
|
||||
assert_eq!(task.force_format(), Some(PixelFormat::F32));
|
||||
let task = make(ForceParams {
|
||||
force_format: 0,
|
||||
..ForceParams::default()
|
||||
});
|
||||
assert_eq!(task.force_format(), Some(PixelFormat::U8));
|
||||
let task = make(ForceParams {
|
||||
force_format: 99,
|
||||
..ForceParams::default()
|
||||
});
|
||||
assert_eq!(task.force_format(), Some(PixelFormat::Invalid));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn timebase_falls_back_for_missing_or_null_params() {
|
||||
let project = project();
|
||||
let make = |params: Option<VideoParams>| {
|
||||
RenderTask::new(
|
||||
Task::new("t", None),
|
||||
params,
|
||||
(project.clone(), NodeId::INVALID),
|
||||
ForceParams::default(),
|
||||
None,
|
||||
)
|
||||
};
|
||||
|
||||
// No params at all.
|
||||
assert_eq!(make(None).timebase(), Rational::new(1, 1));
|
||||
|
||||
// Null frame rate (the default `VideoParams`).
|
||||
assert_eq!(make(Some(VideoParams::new())).timebase(), Rational::new(1, 1));
|
||||
|
||||
// A valid frame rate flips into the frame duration.
|
||||
let mut params = VideoParams::new_basic(
|
||||
64,
|
||||
64,
|
||||
OakPixelFormat::from_code(0),
|
||||
4,
|
||||
1,
|
||||
1,
|
||||
0,
|
||||
1,
|
||||
);
|
||||
params.set_frame_rate(25, 1);
|
||||
assert_eq!(make(Some(params)).timebase(), Rational::new(1, 25));
|
||||
}
|
||||
|
||||
/// The placeholder render state is a valid, empty task.
|
||||
#[test]
|
||||
fn placeholder_is_closed_and_empty() {
|
||||
let task = RenderTask::placeholder();
|
||||
assert_eq!(task.total_frames(), 0);
|
||||
assert_eq!(task.viewer.1, NodeId::INVALID);
|
||||
assert!(task.viewer.0.lock().is_ok());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn classify_ticket_maps_submitter_keys_only() {
|
||||
let task = RenderTask::placeholder();
|
||||
let mut keys = HashMap::new();
|
||||
keys.insert(TicketId(7), (TICKET_VIDEO, 0, 1));
|
||||
keys.insert(TicketId(9), (TICKET_AUDIO, 0, 1));
|
||||
let mut slots = HashMap::new();
|
||||
slots.insert((TICKET_VIDEO, 0, 1), 3);
|
||||
|
||||
assert_eq!(task.classify_ticket(&keys, TicketId(7), &slots), Some(3));
|
||||
// Audio key without a slot.
|
||||
assert_eq!(task.classify_ticket(&keys, TicketId(9), &slots), None);
|
||||
// Unknown ticket id.
|
||||
assert_eq!(task.classify_ticket(&keys, TicketId(99), &slots), None);
|
||||
// Same key with a different time.
|
||||
assert_eq!(
|
||||
task.classify_ticket(&keys, TicketId(7), &HashMap::new()),
|
||||
None
|
||||
);
|
||||
}
|
||||
|
||||
// ---- graph -> ticket params -----------------------------------------
|
||||
|
||||
/// Build a track node holding `blocks` (mutated directly: the graph is
|
||||
/// only inspected by the montage builders, never rendered here).
|
||||
fn track_with(project: &ProjectRef, kind: TrackType, blocks: Vec<NodeId>) -> NodeId {
|
||||
let mut guard = project.lock().unwrap();
|
||||
let track = guard
|
||||
.graph
|
||||
.add_node(NodeCore::new(), Box::new(TrackBehavior::new(kind)));
|
||||
if let Some(t) = guard
|
||||
.graph
|
||||
.get_mut(track)
|
||||
.and_then(|e| e.behavior.as_any_mut())
|
||||
.and_then(|a| a.downcast_mut::<TrackBehavior>())
|
||||
{
|
||||
t.blocks = blocks;
|
||||
}
|
||||
track
|
||||
}
|
||||
|
||||
fn track_list_with(project: &ProjectRef, kind: TrackType, tracks: Vec<NodeId>) -> NodeId {
|
||||
let mut guard = project.lock().unwrap();
|
||||
let list = guard.graph.add_node(
|
||||
NodeCore::new(),
|
||||
Box::new(TrackListBehavior::new(kind)),
|
||||
);
|
||||
if let Some(l) = guard
|
||||
.graph
|
||||
.get_mut(list)
|
||||
.and_then(|e| e.behavior.as_any_mut())
|
||||
.and_then(|a| a.downcast_mut::<TrackListBehavior>())
|
||||
{
|
||||
l.tracks = tracks;
|
||||
}
|
||||
list
|
||||
}
|
||||
|
||||
fn push_track_list(project: &ProjectRef, sequence: NodeId, list: NodeId) {
|
||||
let mut guard = project.lock().unwrap();
|
||||
if let Some(seq) = guard
|
||||
.graph
|
||||
.get_mut(sequence)
|
||||
.and_then(|e| e.behavior.as_any_mut())
|
||||
.and_then(|a| a.downcast_mut::<SequenceBehavior>())
|
||||
{
|
||||
seq.track_lists.push(list);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn build_video_ticket_rejects_missing_and_non_viewer_nodes() {
|
||||
let project = project();
|
||||
for viewer in [NodeId::INVALID, add_folder(&project)] {
|
||||
let task = RenderTask::new(
|
||||
Task::new("t", None),
|
||||
None,
|
||||
(project.clone(), viewer),
|
||||
ForceParams::default(),
|
||||
None,
|
||||
);
|
||||
let error = task.build_video_ticket(Rational::new(0, 1)).unwrap_err();
|
||||
assert!(
|
||||
error.to_string().contains("viewer output"),
|
||||
"viewer {viewer:?}: {error}"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn build_video_ticket_marshals_footage_force_params() {
|
||||
let project = project();
|
||||
let footage = add_footage(&project, "/tmp/oaktask-inline.mp4");
|
||||
let mut task = RenderTask::new(
|
||||
Task::new("t", None),
|
||||
Some(VideoParams::new()),
|
||||
(project.clone(), footage),
|
||||
ForceParams {
|
||||
force_width: 32,
|
||||
force_height: 16,
|
||||
force_format: PixelFormat::F32 as i32,
|
||||
..ForceParams::default()
|
||||
},
|
||||
None,
|
||||
);
|
||||
task.set_render_inputs(0, false, TimeRange::default());
|
||||
|
||||
let params = task.build_video_ticket(Rational::new(1, 2)).unwrap();
|
||||
assert_eq!(params.viewer, footage.identity());
|
||||
assert_eq!(
|
||||
params.footage,
|
||||
Some(("/tmp/oaktask-inline.mp4".to_string(), 0))
|
||||
);
|
||||
assert!(params.montage.is_empty());
|
||||
assert_eq!(params.force_size, Some((32, 16)));
|
||||
assert_eq!(params.force_format, Some(PixelFormat::F32));
|
||||
assert_eq!(params.cache, Some(footage.identity()));
|
||||
assert_eq!(params.time, Rational::new(1, 2));
|
||||
assert_eq!(params.project, task.viewer.0.lock().unwrap().uuid);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn video_montage_skips_degenerate_entries_and_keeps_valid_clips() {
|
||||
let project = project();
|
||||
let sequence = add_sequence(&project);
|
||||
let footage = add_footage(&project, "valid.mp4");
|
||||
let folder = add_folder(&project);
|
||||
|
||||
let ok = add_clip(
|
||||
&project,
|
||||
Some(footage),
|
||||
TimeRange::new(Rational::new(0, 1), Rational::new(1, 1)),
|
||||
);
|
||||
let out_of_range = add_clip(
|
||||
&project,
|
||||
Some(footage),
|
||||
TimeRange::new(Rational::new(10, 1), Rational::new(11, 1)),
|
||||
);
|
||||
let no_footage = add_clip(
|
||||
&project,
|
||||
None,
|
||||
TimeRange::new(Rational::new(0, 1), Rational::new(1, 1)),
|
||||
);
|
||||
let wrong_footage = add_clip(
|
||||
&project,
|
||||
None,
|
||||
TimeRange::new(Rational::new(0, 1), Rational::new(1, 1)),
|
||||
);
|
||||
crate::nodeops::clip_set_footage(&project, wrong_footage, folder);
|
||||
|
||||
// Track blocks: a non-block node, the valid clip, and every rejected
|
||||
// clip kind.
|
||||
let track = track_with(
|
||||
&project,
|
||||
TrackType::Video,
|
||||
vec![folder, ok, out_of_range, no_footage, wrong_footage],
|
||||
);
|
||||
// A track list with bad track ids mixed in.
|
||||
let list = track_list_with(
|
||||
&project,
|
||||
TrackType::Video,
|
||||
vec![NodeId::INVALID, folder, track],
|
||||
);
|
||||
let audio_list = track_list_with(&project, TrackType::Audio, vec![track]);
|
||||
// A valid id that is not a track list.
|
||||
let not_a_list = folder;
|
||||
// A valid id whose node was removed from the graph (the
|
||||
// `graph.get` miss branch).
|
||||
let removed = add_folder(&project);
|
||||
{
|
||||
let mut guard = project.lock().unwrap();
|
||||
let _ = guard.graph.remove_node(removed);
|
||||
assert!(removed.valid() && guard.graph.get(removed).is_none());
|
||||
}
|
||||
{
|
||||
let mut guard = project.lock().unwrap();
|
||||
if let Some(seq) = guard
|
||||
.graph
|
||||
.get_mut(sequence)
|
||||
.and_then(|e| e.behavior.as_any_mut())
|
||||
.and_then(|a| a.downcast_mut::<SequenceBehavior>())
|
||||
{
|
||||
seq.track_lists = vec![NodeId::INVALID, removed, not_a_list, audio_list, list];
|
||||
}
|
||||
}
|
||||
|
||||
let montage = RenderTask::video_montage(&project, sequence, Rational::new(0, 1));
|
||||
assert_eq!(montage.len(), 1, "only the valid clip is kept");
|
||||
assert_eq!(montage[0].filename, "valid.mp4");
|
||||
assert_eq!(montage[0].in_time, Rational::new(0, 1));
|
||||
assert_eq!(montage[0].out_time, Rational::new(1, 1));
|
||||
assert_eq!(montage[0].media_in, Rational::new(0, 1));
|
||||
assert_eq!(montage[0].gain, 1.0);
|
||||
assert_eq!(montage[0].stream_index, 0);
|
||||
|
||||
// A time outside the clip's range yields an empty montage.
|
||||
assert!(RenderTask::video_montage(&project, sequence, Rational::new(5, 1)).is_empty());
|
||||
|
||||
// Missing sequence and non-sequence viewers are empty, not panics.
|
||||
assert!(RenderTask::video_montage(&project, NodeId::INVALID, Rational::new(0, 1)).is_empty());
|
||||
assert!(RenderTask::video_montage(&project, folder, Rational::new(0, 1)).is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn audio_montage_filters_kinds_and_drops_effects() {
|
||||
let project = project();
|
||||
let sequence = add_sequence(&project);
|
||||
let footage = add_footage(&project, "audio.mp4");
|
||||
let folder = add_folder(&project);
|
||||
|
||||
let ok = add_clip(
|
||||
&project,
|
||||
Some(footage),
|
||||
TimeRange::new(Rational::new(0, 1), Rational::new(2, 1)),
|
||||
);
|
||||
let out_of_range = add_clip(
|
||||
&project,
|
||||
Some(footage),
|
||||
TimeRange::new(Rational::new(9, 1), Rational::new(10, 1)),
|
||||
);
|
||||
let no_footage = add_clip(
|
||||
&project,
|
||||
None,
|
||||
TimeRange::new(Rational::new(0, 1), Rational::new(2, 1)),
|
||||
);
|
||||
let wrong_footage = add_clip(
|
||||
&project,
|
||||
None,
|
||||
TimeRange::new(Rational::new(0, 1), Rational::new(2, 1)),
|
||||
);
|
||||
crate::nodeops::clip_set_footage(&project, wrong_footage, folder);
|
||||
|
||||
let track = track_with(
|
||||
&project,
|
||||
TrackType::Audio,
|
||||
vec![NodeId::INVALID, folder, ok, out_of_range, no_footage, wrong_footage],
|
||||
);
|
||||
let audio_list = track_list_with(
|
||||
&project,
|
||||
TrackType::Audio,
|
||||
vec![NodeId::INVALID, folder, track],
|
||||
);
|
||||
let video_list = track_list_with(&project, TrackType::Video, vec![track]);
|
||||
let removed = add_folder(&project);
|
||||
{
|
||||
let mut guard = project.lock().unwrap();
|
||||
let _ = guard.graph.remove_node(removed);
|
||||
assert!(removed.valid() && guard.graph.get(removed).is_none());
|
||||
}
|
||||
{
|
||||
let mut guard = project.lock().unwrap();
|
||||
if let Some(seq) = guard
|
||||
.graph
|
||||
.get_mut(sequence)
|
||||
.and_then(|e| e.behavior.as_any_mut())
|
||||
.and_then(|a| a.downcast_mut::<SequenceBehavior>())
|
||||
{
|
||||
seq.track_lists = vec![video_list, NodeId::INVALID, removed, audio_list];
|
||||
}
|
||||
}
|
||||
|
||||
let montage = RenderTask::audio_montage(&project, sequence, Rational::new(1, 1));
|
||||
assert_eq!(montage.len(), 1);
|
||||
assert_eq!(montage[0].filename, "audio.mp4");
|
||||
assert!(montage[0].effects.is_empty(), "audio carries no effects");
|
||||
|
||||
assert!(RenderTask::audio_montage(&project, sequence, Rational::new(5, 1)).is_empty());
|
||||
assert!(RenderTask::audio_montage(&project, NodeId::INVALID, Rational::new(0, 1)).is_empty());
|
||||
assert!(RenderTask::audio_montage(&project, folder, Rational::new(0, 1)).is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn build_audio_ticket_reads_sequence_params_and_montage() {
|
||||
let project = project();
|
||||
let sequence = add_sequence(&project);
|
||||
let footage = add_footage(&project, "audio.mp4");
|
||||
let clip = add_clip(
|
||||
&project,
|
||||
Some(footage),
|
||||
TimeRange::new(Rational::new(0, 1), Rational::new(1, 1)),
|
||||
);
|
||||
let track = track_with(&project, TrackType::Audio, vec![clip]);
|
||||
let list = track_list_with(&project, TrackType::Audio, vec![track]);
|
||||
push_track_list(&project, sequence, list);
|
||||
|
||||
let task = RenderTask::new(
|
||||
Task::new("t", None),
|
||||
None,
|
||||
(project.clone(), sequence),
|
||||
ForceParams::default(),
|
||||
None,
|
||||
);
|
||||
let params = task
|
||||
.build_audio_ticket(TimeRange::new(Rational::new(0, 1), Rational::new(1, 1)))
|
||||
.unwrap();
|
||||
assert_eq!(params.viewer, sequence.identity());
|
||||
assert_eq!(params.sample_rate, 48000);
|
||||
assert_eq!(params.channel_layout, 0x3);
|
||||
assert_eq!(params.montage.len(), 1);
|
||||
assert_eq!(params.montage[0].filename, "audio.mp4");
|
||||
assert_eq!(params.range.in_(), Rational::new(0, 1));
|
||||
|
||||
// A footage viewer still yields a ticket (with an empty montage).
|
||||
let footage_task = RenderTask::new(
|
||||
Task::new("t", None),
|
||||
None,
|
||||
(project.clone(), footage),
|
||||
ForceParams::default(),
|
||||
None,
|
||||
);
|
||||
let params = footage_task
|
||||
.build_audio_ticket(TimeRange::new(Rational::new(0, 1), Rational::new(1, 2)))
|
||||
.unwrap();
|
||||
assert!(params.montage.is_empty());
|
||||
assert_eq!(params.sample_rate, 48000);
|
||||
|
||||
// A stale viewer falls back to the defaults too (no panic).
|
||||
let stale = RenderTask::new(
|
||||
Task::new("t", None),
|
||||
None,
|
||||
(project, NodeId::INVALID),
|
||||
ForceParams::default(),
|
||||
None,
|
||||
);
|
||||
let params = stale
|
||||
.build_audio_ticket(TimeRange::new(Rational::new(0, 1), Rational::new(1, 2)))
|
||||
.unwrap();
|
||||
assert!(params.montage.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn clip_effects_walks_chain_params_and_enable_state() {
|
||||
let project = project();
|
||||
let footage = add_footage(&project, "fx.mp4");
|
||||
let folder = add_folder(&project);
|
||||
|
||||
// A node without an effect input ends the walk immediately.
|
||||
{
|
||||
let guard = project.lock().unwrap();
|
||||
assert!(RenderTask::clip_effects(&guard.graph, footage).is_empty());
|
||||
assert!(RenderTask::clip_effects(&guard.graph, folder).is_empty());
|
||||
assert!(RenderTask::clip_effects(&guard.graph, NodeId::INVALID).is_empty());
|
||||
}
|
||||
|
||||
// A clip whose effect input is set but not connected: the walk's
|
||||
// no-upstream break.
|
||||
let bare = add_clip(
|
||||
&project,
|
||||
None,
|
||||
TimeRange::new(Rational::new(0, 1), Rational::new(1, 1)),
|
||||
);
|
||||
{
|
||||
let guard = project.lock().unwrap();
|
||||
assert!(RenderTask::clip_effects(&guard.graph, bare).is_empty());
|
||||
}
|
||||
|
||||
// Direct footage -> clip connection: the source node is dropped
|
||||
// (the montage decodes the footage itself).
|
||||
let direct = add_clip(
|
||||
&project,
|
||||
Some(footage),
|
||||
TimeRange::new(Rational::new(0, 1), Rational::new(1, 1)),
|
||||
);
|
||||
{
|
||||
let mut guard = project.lock().unwrap();
|
||||
guard
|
||||
.graph
|
||||
.connect(footage, direct, oak_node::block::clip_input::TEXTURE_INPUT, -1)
|
||||
.expect("connect footage");
|
||||
}
|
||||
{
|
||||
let guard = project.lock().unwrap();
|
||||
assert!(RenderTask::clip_effects(&guard.graph, direct).is_empty());
|
||||
}
|
||||
|
||||
for enabled in [true, false] {
|
||||
let clip = add_clip(&project, None, TimeRange::new(Rational::new(0, 1), Rational::new(1, 1)));
|
||||
let (ecore, ebehavior) = oak_node::factory::Factory::global()
|
||||
.create_any("org.olivevideoeditor.Olive.invert")
|
||||
.expect("invert registered");
|
||||
let mut guard = project.lock().unwrap();
|
||||
let effect = guard.graph.add_node(ecore, ebehavior);
|
||||
guard
|
||||
.graph
|
||||
.get_mut(effect)
|
||||
.unwrap()
|
||||
.core
|
||||
.set_standard_value(
|
||||
oak_node::node::ENABLED_INPUT,
|
||||
-1,
|
||||
NodeValue::Boolean(enabled),
|
||||
);
|
||||
guard
|
||||
.graph
|
||||
.connect(footage, effect, "tex_in", -1)
|
||||
.expect("footage -> effect");
|
||||
guard
|
||||
.graph
|
||||
.connect(
|
||||
effect,
|
||||
clip,
|
||||
oak_node::block::clip_input::TEXTURE_INPUT,
|
||||
-1,
|
||||
)
|
||||
.expect("effect -> clip");
|
||||
|
||||
let effects = RenderTask::clip_effects(&guard.graph, clip);
|
||||
assert_eq!(effects.len(), 1);
|
||||
assert_eq!(
|
||||
effects[0].type_id,
|
||||
"org.olivevideoeditor.Olive.invert"
|
||||
);
|
||||
assert_eq!(effects[0].enabled, enabled);
|
||||
assert_eq!(effects[0].effect_input_id.as_deref(), Some("tex_in"));
|
||||
assert!(
|
||||
!effects[0].params.is_empty(),
|
||||
"the four channel toggles are collected"
|
||||
);
|
||||
}
|
||||
|
||||
// A transform effect contributes scalars but skips its texture and
|
||||
// matrix inputs; a blur effect additionally skips its hidden
|
||||
// method-specific inputs; a volume effect skips its samples input.
|
||||
for (type_name, connect_input, skipped) in [
|
||||
("org.olivevideoeditor.Olive.transform", "tex_in", "parent_in"),
|
||||
(
|
||||
"org.olivevideoeditor.Olive.blur",
|
||||
"tex_in",
|
||||
"directional_degrees_in",
|
||||
),
|
||||
(
|
||||
"org.olivevideoeditor.Olive.volume",
|
||||
"samples_in",
|
||||
"samples_in",
|
||||
),
|
||||
] {
|
||||
let clip = add_clip(
|
||||
&project,
|
||||
None,
|
||||
TimeRange::new(Rational::new(0, 1), Rational::new(1, 1)),
|
||||
);
|
||||
let (ecore, ebehavior) = oak_node::factory::Factory::global()
|
||||
.create_any(type_name)
|
||||
.expect("effect registered");
|
||||
let mut guard = project.lock().unwrap();
|
||||
let effect = guard.graph.add_node(ecore, ebehavior);
|
||||
guard
|
||||
.graph
|
||||
.connect(footage, effect, connect_input, -1)
|
||||
.expect("footage -> effect");
|
||||
guard
|
||||
.graph
|
||||
.connect(
|
||||
effect,
|
||||
clip,
|
||||
oak_node::block::clip_input::TEXTURE_INPUT,
|
||||
-1,
|
||||
)
|
||||
.expect("effect -> clip");
|
||||
|
||||
let effects = RenderTask::clip_effects(&guard.graph, clip);
|
||||
assert_eq!(effects.len(), 1, "{type_name}");
|
||||
assert_eq!(effects[0].type_id, type_name);
|
||||
assert!(
|
||||
effects[0].params.iter().all(|(id, _)| id != skipped),
|
||||
"{type_name}: {skipped} must be skipped"
|
||||
);
|
||||
assert!(
|
||||
effects[0].params.iter().all(|(id, _)| id != "tex_in"),
|
||||
"{type_name}: texture inputs must be skipped"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// ---- dispatch channel ------------------------------------------------
|
||||
|
||||
#[test]
|
||||
fn dispatch_queue_pops_in_order_and_counts_running() {
|
||||
let dispatch = Box::into_raw(Box::new(RenderDispatch::new()));
|
||||
let audio = || {
|
||||
TicketPayload::Audio(AudioSamples {
|
||||
samples: vec![0.25, -0.25],
|
||||
sample_rate: 48000,
|
||||
channel_layout: 0x3,
|
||||
channel_count: 2,
|
||||
})
|
||||
};
|
||||
|
||||
// Empty queue -> None.
|
||||
assert!(unsafe { (*dispatch).pop_finished() }.is_none());
|
||||
|
||||
unsafe {
|
||||
(*dispatch).running.store(2, Ordering::SeqCst);
|
||||
}
|
||||
push_finished(TicketId(1), Ok(audio()), DispatchPtr(dispatch));
|
||||
push_finished(TicketId(2), Ok(audio()), DispatchPtr(dispatch));
|
||||
assert_eq!(unsafe { (*dispatch).running.load(Ordering::SeqCst) }, 0);
|
||||
|
||||
let first = unsafe { (*dispatch).pop_finished() };
|
||||
assert!(matches!(first, Some((TicketId(1), Ok(TicketPayload::Audio(_))))));
|
||||
let second = unsafe { (*dispatch).pop_finished() };
|
||||
assert!(matches!(second, Some((TicketId(2), Ok(TicketPayload::Audio(_))))));
|
||||
assert!(unsafe { (*dispatch).pop_finished() }.is_none());
|
||||
|
||||
unsafe {
|
||||
drop(Box::from_raw(dispatch));
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn wait_idle_returns_when_callbacks_finish() {
|
||||
let dispatch = RenderDispatch::new();
|
||||
dispatch.running.store(1, Ordering::SeqCst);
|
||||
let calls = AtomicUsize::new(0);
|
||||
let pump = || {
|
||||
// The first pump observes an in-flight ticket (so `wait_idle`
|
||||
// takes its wait branch); the second one completes it. No
|
||||
// ticket thread is involved, so the test stays deterministic.
|
||||
if calls.fetch_add(1, Ordering::SeqCst) >= 1 {
|
||||
dispatch.running.store(0, Ordering::SeqCst);
|
||||
dispatch.cv.notify_all();
|
||||
}
|
||||
};
|
||||
dispatch.wait_idle(&pump);
|
||||
assert!(calls.load(Ordering::SeqCst) >= 2, "the wait branch ran");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn wait_idle_breaks_immediately_when_idle() {
|
||||
let dispatch = RenderDispatch::new();
|
||||
let calls = AtomicUsize::new(0);
|
||||
dispatch.wait_idle(&|| {
|
||||
calls.fetch_add(1, Ordering::SeqCst);
|
||||
});
|
||||
assert_eq!(calls.load(Ordering::SeqCst), 1, "pump once, then break");
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user