render: stack higher-numbered video tracks on top

Match the timeline UI (V_max drawn topmost): composite tracks from
V1 up to V_max so the highest-numbered track is composited last, in
both the montage path and direct graph evaluation.
This commit is contained in:
2026-08-27 15:20:18 +08:00
parent 663d879908
commit c03f1ec604
6 changed files with 207 additions and 34 deletions
+9 -6
View File
@@ -1008,8 +1008,9 @@ fn composite_tracks(frames: Vec<Frame>, size: (i32, i32)) -> Frame {
/// Render one frame of `viewer` (a sequence) at `time`: evaluate every
/// enabled clip overlapping `time` through the node graph (one traverser
/// pass per clip; the hooks' decoder cache is shared across clips) and
/// composite the resulting frames topmost-first — in the video track
/// list, track 0 is the topmost stack element (C++ `TrackList` order).
/// composite the resulting frames bottommost-first — in the video track
/// list the LAST track (the highest-numbered one) is the topmost stack
/// element (NLE stacking, matching the timeline UI).
///
/// `size` is the decode target for every clip, so all frames composite
/// without per-frame scaling. Errors: `Invalid` for a non-F32 format or a
@@ -1038,8 +1039,9 @@ pub fn render_graph_frame(
// Collect the clips covering `time`: the sequence's track lists (video
// then audio — C++ `Sequence` keeps them in the `k_track_input_format`
// array order), the video list's tracks in stack order, then each
// track's blocks.
// array order), the video list's tracks topmost-first (the list's last
// track is the top of the stack; `composite_tracks` walks the frames in
// reverse and draws the first frame last), then each track's blocks.
let mut clips: Vec<oak_node::id::NodeId> = Vec::new();
for tl_id in &sequence.track_lists {
let Some(tl) = graph.get(*tl_id) else {
@@ -1055,7 +1057,7 @@ pub fn render_graph_frame(
if tl.kind != oak_node::track::TrackType::Video {
continue;
}
for track_id in &tl.tracks {
for track_id in tl.tracks.iter().rev() {
let Some(track) = graph.get(*track_id) else {
continue;
};
@@ -1297,7 +1299,8 @@ fn scale_rgba_f32(
}
/// Composite the montage at `time`: decode each covering clip and
/// alpha-composite topmost-last (C++ track order: track 0 is topmost).
/// alpha-composite topmost-last (NLE track order: the highest-numbered
/// track is topmost).
fn render_montage_frame(
time: Rational,
params: &crate::ticket::VideoTicketParams,
+49 -2
View File
@@ -19,7 +19,8 @@
//! Builds a real node graph (sequence -> video track list -> video tracks
//! -> clip blocks -> footage), evaluates the clip overlapping the request
//! time through the traverser and composites the decoded frames — the same
//! path the engine's viewer uses. Track 0 is the topmost stack element.
//! path the engine's viewer uses. The LAST track is the topmost stack
//! element (NLE stacking: the highest-numbered track wins).
use std::sync::{Arc, Mutex};
@@ -43,7 +44,8 @@ fn clip_path(tag: &str) -> std::path::PathBuf {
}
/// One sequence + one video track list with one track per clip
/// `(filename, [in, out))`. Track 0 (the first entry) composites on top.
/// `(filename, [in, out))`. The LAST entry's track composites on top
/// (NLE stacking: the highest-numbered track is topmost).
fn build_project(clips: &[(&str, Rational, Rational)]) -> (Arc<Mutex<Project>>, NodeId) {
let project = Project::new();
let seq;
@@ -191,6 +193,51 @@ fn graph_sequence_renders_two_tracks() {
let _ = std::fs::remove_file(&path_b);
}
/// NLE stacking regression: two OPAQUE solid-color clips covering the
/// same time on two video tracks — the clip on the LAST track (V2, blue)
/// composites on top of the clip on the first track (V1, red), matching
/// the timeline UI (the highest-numbered track displays on top).
#[test]
fn graph_sequence_stacks_highest_track_on_top() {
let red = clip_path("stack_red");
let blue = clip_path("stack_blue");
oak_codec::testmedia::write_test_clip_solid(&red, 64, 64, 10, 10, [0.9, 0.1, 0.1, 1.0])
.expect("red clip generation");
oak_codec::testmedia::write_test_clip_solid(&blue, 64, 64, 10, 10, [0.1, 0.1, 0.9, 1.0])
.expect("blue clip generation");
// V1 = red (bottom), V2 = blue (top).
let (project, seq) = build_project(&[
(&red.to_string_lossy(), Rational::new(0, 1), Rational::new(1, 1)),
(&blue.to_string_lossy(), Rational::new(0, 1), Rational::new(1, 1)),
]);
let tex = oak_render::eval::render_graph_frame(&project, seq, Rational::new(0, 1), (64, 64), PixelFormat::F32)
.expect("stacked render");
let data = frame_data(&tex);
assert!(
channel(data, 8, 8, 2) > 0.5 && channel(data, 8, 8, 0) < 0.4,
"V2's blue covers V1's red (r={}, b={})",
channel(data, 8, 8, 0),
channel(data, 8, 8, 2)
);
// Distinguishability guard: solo, the V1 clip really is red (the two
// tracks carry different content).
let (solo, solo_seq) = build_project(&[(&red.to_string_lossy(), Rational::new(0, 1), Rational::new(1, 1))]);
let solo_tex = oak_render::eval::render_graph_frame(&solo, solo_seq, Rational::new(0, 1), (64, 64), PixelFormat::F32)
.expect("solo V1 render");
let solo_data = frame_data(&solo_tex);
assert!(
channel(solo_data, 8, 8, 0) > 0.5 && channel(solo_data, 8, 8, 2) < 0.4,
"solo V1 is red (r={}, b={})",
channel(solo_data, 8, 8, 0),
channel(solo_data, 8, 8, 2)
);
let _ = std::fs::remove_file(&red);
let _ = std::fs::remove_file(&blue);
}
/// The driver rejects bad arguments explainably: non-F32 format, a
/// non-positive size, and a missing viewer.
#[test]