node: virtual graph endpoints and the Kahn-order BFS sweep (M0b core)

Per docs/zh/plans/render-pipeline-threads.md §3.8:

- oak-node/nodes/graphendpoints.rs: the GraphInput/GraphOutput
  virtual node pair — factory-registered but hidden from every create
  menu, duplicate refused, real value() semantics (the input forwards
  its feed_in row, the output publishes its tex_in as the frame).
  The input endpoint also declares a connectable feed_in port
  (documented deviation: footage/generator sources have no connectable
  inputs, so the walk needs a feeder anchor).
- graph.rs: ensure_endpoints/endpoints/is_endpoint — idempotent,
  identified by type id, default input->output edge only while the
  output's tex_in is free; remove_node refuses endpoints.
- project.rs + serializer.rs: every project graph carries the pair;
  a legacy file without endpoints migrates on load (roundtrip and
  legacy-migration tests, re-save is idempotent).
- traverser.rs: eval_graph_bfs — the endpoint-to-endpoint Kahn
  sweep. Live set = (input's forward cone U its feeder cone) INTERSECT
  (output's backward cone); multi-input nodes dequeue at zero
  in-degree over the live subgraph; deterministic ascending-id ready
  order (Graph::edges is a BTreeSet, so insertion order is
  unrecoverable — documented); time-shifted upstreams pull through
  the shared DFS memo (walk_dfs, factored out of evaluate);
  un-orderable remainder reports a named cycle; missing endpoints /
  unreachable output are errors. Eight BFS tests cover the plan's
  acceptance bullets.
- oak-render: bfs_endpoint_sweep_renders_footage_through_position —
  real clip through a real Position node via the sweep, shifted
  pixels asserted against a reference decode.
- Endpoint names localized in all eight i18n packs; storage/structure
  tests updated for the two extra nodes.
This commit is contained in:
2026-09-11 15:13:53 +08:00
parent 3a48dd4991
commit 29204d1f63
24 changed files with 1849 additions and 81 deletions
+117
View File
@@ -3989,5 +3989,122 @@ mod tests {
}
}
// ---- Endpoint-anchored BFS sweep (M0b) ------------------------------
/// The M0b integration path over real media and a real effect: a test
/// clip probed into a footage node feeds `GraphInput.feed_in`, a
/// Position node sits between the endpoints, and `eval_graph_bfs`
/// decodes the frame, runs the effect pass and returns the output
/// endpoint's texture. The Position offset is `(16, 0)`, so the frame
/// must come out shifted right by 16 pixels — the pixels alone prove
/// both the decode and the shader pass ran inside the sweep.
#[test]
fn bfs_endpoint_sweep_renders_footage_through_position() {
use oak_node::nodes::graphendpoints::{GRAPH_INPUT_FEED_INPUT, GRAPH_OUTPUT_INPUT};
if oak_core::backend::GpuContext::shared().is_none() {
eprintln!("no adapter; skipping");
return;
}
let path = std::env::temp_dir().join(format!(
"oakrender_bfs_endpoints_{}.mp4",
std::process::id()
));
oak_codec::testmedia::write_test_clip_solid(
&path,
64,
64,
10,
10,
[0.9, 0.2, 0.1, 1.0],
)
.expect("test clip generation");
let mut graph = oak_node::graph::Graph::new();
let (input, output) = graph.ensure_endpoints();
// The default input -> output edge is replaced by the effect chain.
graph.disconnect(input, output, GRAPH_OUTPUT_INPUT, -1);
let (core, _) = oak_node::footage::FootageBehavior::create();
let mut footage = oak_node::footage::FootageBehavior::new(&path.to_string_lossy());
footage.probe().expect("probe the test clip");
let footage = graph.add_node(core, Box::new(footage));
let (core, behavior) = oak_node::factory::Factory::global()
.create_any("org.olivevideoeditor.Olive.position")
.expect("position node registered");
let position = graph.add_node(core, behavior);
graph
.get_mut(position)
.expect("the position node is live")
.core
.set_standard_value("offset_in", -1, NodeValue::Vec2([16.0, 0.0]));
graph
.connect(footage, input, GRAPH_INPUT_FEED_INPUT, -1)
.expect("footage -> GraphInput.feed_in");
graph
.connect(input, position, "tex_in", -1)
.expect("GraphInput.tex_out -> position.tex_in");
graph
.connect(position, output, GRAPH_OUTPUT_INPUT, -1)
.expect("position.tex_out -> GraphOutput.tex_in");
let mut hooks = RenderEvalHooks::new();
hooks.frame_size = Some((64, 64));
let value = oak_node::traverser::Traverser::new()
.eval_graph_bfs(&graph, Rational::new(0, 1), &mut hooks)
.expect("the endpoint sweep runs");
let NodeValue::Texture(handle) = value else {
panic!("the sweep must return the output endpoint's texture");
};
assert!(!handle.ctx.is_null(), "the returned box is still a job");
let texture = (unsafe { oak_node::handle::get_checked::<Texture>(&handle) })
.cloned()
.expect("the returned box holds a Texture");
assert_eq!(texture.size(), (64, 64));
let frame = texture.to_frame().expect("readback");
let reference = render_footage_frame(
&path.to_string_lossy(),
0,
Rational::new(0, 1),
(64, 64),
PixelFormat::F32,
)
.expect("reference decode");
let reference = reference.to_frame().expect("reference readback");
let reference_px = pixel_at(&reference, 32, 32);
assert!(
reference_px[0] > 0.3 && reference_px[0] > 4.0 * reference_px[1],
"the reference clip is red-dominant: {reference_px:?}"
);
// The vacated left columns are transparent, not a clamped edge
// column (the Position node's whole-pixel translation).
for (x, y) in [(0, 0), (8, 32), (15, 63)] {
assert!(
pixel_at(&frame, x, y)[3] < 1e-4,
"({x},{y}) must be transparent after the shift: {:?}",
pixel_at(&frame, x, y)
);
}
// The footage content arrives at (x + 16, y).
for (x, y) in [(16, 0), (32, 32), (63, 63)] {
let got = pixel_at(&frame, x, y);
let want = pixel_at(&reference, x - 16, y);
for (c, (g, w)) in got.iter().zip(want).enumerate() {
assert!(
(g - w).abs() < 1e-3,
"shifted pixel ({x},{y}) ch{c}: got {g}, want {w}"
);
}
}
let _ = std::fs::remove_file(&path);
}
}