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
+14 -9
View File
@@ -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);