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:
2026-09-22 20:54:04 +08:00
parent 666ac9b4d4
commit e04ce03058
11 changed files with 5370 additions and 2 deletions
+570 -1
View File
@@ -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, &params);
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, &params);
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();
}
}
+706 -1
View File
@@ -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");
}
}