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:
@@ -97,6 +97,11 @@ const EXPECTED_ORDER: &[&str] = &[
|
||||
"org.olivevideoeditor.Olive.colorbars",
|
||||
"org.olivevideoeditor.Olive.ramp",
|
||||
"org.olivevideoeditor.Olive.multicam",
|
||||
// Oak-only virtual graph endpoints (no C++ counterpart; appended
|
||||
// after the C++ menu order by `nodes::register_all`, hidden from
|
||||
// every create menu).
|
||||
"org.olivevideoeditor.Olive.graphinput",
|
||||
"org.olivevideoeditor.Olive.graphoutput",
|
||||
];
|
||||
|
||||
/// `register_all()` installs every built-in node, exactly once, in C++
|
||||
@@ -110,8 +115,9 @@ fn registered_entries_match_cpp_order() {
|
||||
}
|
||||
}
|
||||
|
||||
/// First and last entries are polygon and multi-cam respectively
|
||||
/// (multi-cam is the last built-in in `factory.cpp` switch order).
|
||||
/// First entry is polygon; the last is the Oak-only graph-output endpoint
|
||||
/// (multi-cam is the last C++ built-in in `factory.cpp` switch order, but
|
||||
/// the endpoint pair is appended after it).
|
||||
#[test]
|
||||
fn first_and_last_entries() {
|
||||
let entries = Factory::global().entries();
|
||||
@@ -122,9 +128,9 @@ fn first_and_last_entries() {
|
||||
assert_eq!(entries.first().unwrap().name, "Polygon");
|
||||
assert_eq!(
|
||||
entries.last().unwrap().type_id,
|
||||
"org.olivevideoeditor.Olive.multicam"
|
||||
"org.olivevideoeditor.Olive.graphoutput"
|
||||
);
|
||||
assert_eq!(entries.last().unwrap().name, "Multi-Cam");
|
||||
assert_eq!(entries.last().unwrap().name, "Graph Output");
|
||||
}
|
||||
|
||||
/// `find()` resolves a type id to its metadata (pan, index 4 in the
|
||||
|
||||
@@ -1207,3 +1207,130 @@ fn multicam_clip_round_trip_preserves_wiring() {
|
||||
.expect("loaded node is a MultiCamNode");
|
||||
assert_eq!(mc_node.sequence(), Some(seq_src));
|
||||
}
|
||||
|
||||
/// The virtual endpoint pair round-trips: the saved file names both
|
||||
/// endpoint types, the loaded graph rebuilds them with their identities,
|
||||
/// and the default `input -> output` wiring survives.
|
||||
#[test]
|
||||
fn endpoints_round_trip_and_stay_wired() {
|
||||
use oak_node::nodes::graphendpoints::{
|
||||
GRAPH_INPUT_TYPE_ID, GRAPH_OUTPUT_INPUT, GRAPH_OUTPUT_TYPE_ID,
|
||||
};
|
||||
use oak_node::project::Project;
|
||||
|
||||
let project = Project::new();
|
||||
let (input, output) = {
|
||||
let mut p = project.lock().unwrap();
|
||||
p.initialize().unwrap();
|
||||
// Some real content so the round trip is not vacuous.
|
||||
let (core, behavior) = (oak_node::factory::Factory::global()
|
||||
.find("org.olivevideoeditor.Olive.math")
|
||||
.unwrap()
|
||||
.create)();
|
||||
p.graph.add_node(core, behavior);
|
||||
p.graph.endpoints().expect("initialize creates the pair")
|
||||
};
|
||||
|
||||
let xml = {
|
||||
let p = project.lock().unwrap();
|
||||
oak_node::serializer::save(&p).unwrap()
|
||||
};
|
||||
assert!(
|
||||
xml.contains(GRAPH_INPUT_TYPE_ID) && xml.contains(GRAPH_OUTPUT_TYPE_ID),
|
||||
"both endpoints are written to the file"
|
||||
);
|
||||
|
||||
let loaded = oak_node::serializer::load(&xml).unwrap();
|
||||
{
|
||||
let l = loaded.lock().unwrap();
|
||||
let (li, lo) = l
|
||||
.graph
|
||||
.endpoints()
|
||||
.expect("the endpoint pair survives the round trip");
|
||||
assert_eq!(
|
||||
(li, lo),
|
||||
(input, output),
|
||||
"loaded endpoints keep their identities"
|
||||
);
|
||||
assert_eq!(
|
||||
l.graph.connected_output(lo, GRAPH_OUTPUT_INPUT, -1),
|
||||
Some(li),
|
||||
"the default wiring survives"
|
||||
);
|
||||
}
|
||||
|
||||
// Re-save is idempotent (no duplicate pair is added by the load-time
|
||||
// `ensure_endpoints`).
|
||||
let xml2 = {
|
||||
let l = loaded.lock().unwrap();
|
||||
oak_node::serializer::save(&l).unwrap()
|
||||
};
|
||||
assert_eq!(xml, xml2, "re-save is idempotent");
|
||||
}
|
||||
|
||||
/// Migration: a project file saved before the endpoints existed (no
|
||||
/// endpoint nodes at all) loads with the pair created and default-wired,
|
||||
/// and re-loading the migrated save adds nothing further.
|
||||
#[test]
|
||||
fn legacy_project_without_endpoints_migrates_on_load() {
|
||||
use oak_node::nodes::graphendpoints::{
|
||||
GRAPH_INPUT_TYPE_ID, GRAPH_OUTPUT_INPUT, GRAPH_OUTPUT_TYPE_ID,
|
||||
};
|
||||
use oak_node::project::Project;
|
||||
|
||||
let project = Project::new();
|
||||
let legacy_count = {
|
||||
let mut p = project.lock().unwrap();
|
||||
p.initialize().unwrap();
|
||||
let (core, behavior) = (oak_node::factory::Factory::global()
|
||||
.find("org.olivevideoeditor.Olive.math")
|
||||
.unwrap()
|
||||
.create)();
|
||||
p.graph.add_node(core, behavior);
|
||||
// Simulate a pre-endpoint file: detach the pair before saving.
|
||||
// `remove_node` refuses endpoints by design, so the file-level
|
||||
// simulation goes through the transfer seam (`take_node`).
|
||||
let (input, output) = p.graph.endpoints().expect("initialize creates the pair");
|
||||
assert!(p.graph.take_node(input).is_some());
|
||||
assert!(p.graph.take_node(output).is_some());
|
||||
assert!(p.graph.endpoints().is_none());
|
||||
p.graph.node_count()
|
||||
};
|
||||
|
||||
let xml = {
|
||||
let p = project.lock().unwrap();
|
||||
oak_node::serializer::save(&p).unwrap()
|
||||
};
|
||||
assert!(
|
||||
!xml.contains(GRAPH_INPUT_TYPE_ID) && !xml.contains(GRAPH_OUTPUT_TYPE_ID),
|
||||
"the simulated legacy file carries no endpoints"
|
||||
);
|
||||
|
||||
let loaded = oak_node::serializer::load(&xml).unwrap();
|
||||
let (li, lo) = {
|
||||
let l = loaded.lock().unwrap();
|
||||
let (li, lo) = l.graph.endpoints().expect("load migrates the pair in");
|
||||
assert_eq!(
|
||||
l.graph.connected_output(lo, GRAPH_OUTPUT_INPUT, -1),
|
||||
Some(li),
|
||||
"the migrated pair is default-wired"
|
||||
);
|
||||
assert_eq!(
|
||||
l.graph.node_count(),
|
||||
legacy_count + 2,
|
||||
"exactly the missing pair is added"
|
||||
);
|
||||
(li, lo)
|
||||
};
|
||||
|
||||
// The migrated save reloads without adding a second pair.
|
||||
let xml2 = {
|
||||
let l = loaded.lock().unwrap();
|
||||
oak_node::serializer::save(&l).unwrap()
|
||||
};
|
||||
let reloaded = oak_node::serializer::load(&xml2).unwrap();
|
||||
let r = reloaded.lock().unwrap();
|
||||
assert_eq!(r.graph.endpoints(), Some((li, lo)));
|
||||
assert_eq!(r.graph.node_count(), legacy_count + 2);
|
||||
assert_eq!(r.graph.connected_output(lo, GRAPH_OUTPUT_INPUT, -1), Some(li));
|
||||
}
|
||||
|
||||
@@ -106,10 +106,14 @@ fn project_lifecycle() {
|
||||
#[test]
|
||||
fn project_deep_copy_isolation() {
|
||||
let project = Project::new();
|
||||
// The value node's id, kept across the lock scope so the copy can be
|
||||
// looked up by identity (deep_copy preserves ids: the copy starts
|
||||
// empty, so every node keeps its arena slot).
|
||||
let a;
|
||||
{
|
||||
let mut p = project.lock().unwrap();
|
||||
p.initialize().unwrap();
|
||||
let a = add_test_node(&mut p.graph);
|
||||
a = add_test_node(&mut p.graph);
|
||||
let b = add_test_node(&mut p.graph);
|
||||
p.graph.connect(a, b, "val_in", -1).unwrap();
|
||||
p.graph
|
||||
@@ -139,11 +143,10 @@ fn project_deep_copy_isolation() {
|
||||
|
||||
// The copy shares no mutable state: mutate the original, the copy
|
||||
// must not see it (no sync has happened yet).
|
||||
let copy_val = copy_guard.graph.node_ids()[1];
|
||||
let copy_val = copy_guard
|
||||
.graph
|
||||
.get(copy_val)
|
||||
.unwrap()
|
||||
.get(a)
|
||||
.expect("the copy keeps the original's node identities")
|
||||
.core
|
||||
.standard_value("val_in", -1)
|
||||
.to_double();
|
||||
@@ -156,11 +159,16 @@ fn project_deep_copy_isolation() {
|
||||
#[test]
|
||||
fn project_sync_copy_consistency() {
|
||||
let project = Project::new();
|
||||
// The two test nodes' ids, kept across the lock scope: the tests look
|
||||
// nodes up by identity rather than by arena index (the project now
|
||||
// also holds the root folder and the two graph endpoints).
|
||||
let a;
|
||||
let b;
|
||||
{
|
||||
let mut p = project.lock().unwrap();
|
||||
p.initialize().unwrap();
|
||||
let a = add_test_node(&mut p.graph);
|
||||
let b = add_test_node(&mut p.graph);
|
||||
a = add_test_node(&mut p.graph);
|
||||
b = add_test_node(&mut p.graph);
|
||||
p.graph.connect(a, b, "val_in", -1).unwrap();
|
||||
p.graph
|
||||
.get_mut(a)
|
||||
@@ -173,9 +181,6 @@ fn project_sync_copy_consistency() {
|
||||
let mut original = project.lock().unwrap();
|
||||
let copied = original.deep_copy().unwrap();
|
||||
let mut copy_guard = copied.lock().unwrap();
|
||||
let ids = original.graph.node_ids();
|
||||
let a = ids[1];
|
||||
let b = ids[2];
|
||||
|
||||
// 1. add a new node c + edge c->b, 2. change a's value.
|
||||
let c = add_test_node(&mut original.graph);
|
||||
|
||||
Reference in New Issue
Block a user