From 29204d1f6355027c984da35e058143cf4e3328a7 Mon Sep 17 00:00:00 2001 From: Mike Solar Date: Fri, 11 Sep 2026 15:13:53 +0800 Subject: [PATCH] node: virtual graph endpoints and the Kahn-order BFS sweep (M0b core) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- assets/i18n/de.yaml | 4 + assets/i18n/en-US.yaml | 4 + assets/i18n/es.yaml | 4 + assets/i18n/fr.yaml | 4 + assets/i18n/ja.yaml | 4 + assets/i18n/pt.yaml | 4 + assets/i18n/ru.yaml | 4 + assets/i18n/zh-CN.yaml | 4 + crates/oak-node/src/graph.rs | 290 ++++++++ crates/oak-node/src/nodes/graphendpoints.rs | 396 ++++++++++ crates/oak-node/src/nodes/mod.rs | 8 + crates/oak-node/src/nodes/textbackend.rs | 18 +- crates/oak-node/src/nodes/textv3.rs | 17 +- crates/oak-node/src/project.rs | 4 + crates/oak-node/src/serializer.rs | 6 + crates/oak-node/src/traverser.rs | 783 +++++++++++++++++++- crates/oak-node/tests/factory_smoke_test.rs | 14 +- crates/oak-node/tests/serializer_test.rs | 127 ++++ crates/oak-node/tests/structure_test.rs | 23 +- crates/oak-render/src/eval.rs | 117 +++ crates/oak-storage/tests/common/mod.rs | 23 +- crates/oak-storage/tests/database_test.rs | 47 +- crates/oak-storage/tests/storage_test.rs | 22 +- docs/zh/plans/render-pipeline-threads.md | 3 + 24 files changed, 1849 insertions(+), 81 deletions(-) create mode 100644 crates/oak-node/src/nodes/graphendpoints.rs diff --git a/assets/i18n/de.yaml b/assets/i18n/de.yaml index 78d5fc778..fb2d431d8 100644 --- a/assets/i18n/de.yaml +++ b/assets/i18n/de.yaml @@ -545,6 +545,10 @@ "node.multicam.input.sources_in": "Sources" "node.multicam.input.sequence_in": "Sequence" "node.multicam.input.sequence_type_in": "Sequence Type" +"node.graphinput.name": "Graph-Eingang" +"node.graphinput.input.feed_in": "Zufuhr" +"node.graphoutput.name": "Graph-Ausgang" +"node.graphoutput.input.tex_in": "Textur" "status.ready": "Bereit" "status.cache": "Cache: Aktiviert" "status.proxy.on": "Proxy: Ein" diff --git a/assets/i18n/en-US.yaml b/assets/i18n/en-US.yaml index e5387a059..7494d4e8f 100644 --- a/assets/i18n/en-US.yaml +++ b/assets/i18n/en-US.yaml @@ -543,6 +543,10 @@ "node.multicam.input.sources_in": "Sources" "node.multicam.input.sequence_in": "Sequence" "node.multicam.input.sequence_type_in": "Sequence Type" +"node.graphinput.name": "Graph Input" +"node.graphinput.input.feed_in": "Feed" +"node.graphoutput.name": "Graph Output" +"node.graphoutput.input.tex_in": "Texture" "status.ready": "Ready" "status.cache": "Cache: Enabled" "status.proxy.on": "Proxy: On" diff --git a/assets/i18n/es.yaml b/assets/i18n/es.yaml index 41188c622..0fdd167b8 100644 --- a/assets/i18n/es.yaml +++ b/assets/i18n/es.yaml @@ -545,6 +545,10 @@ "node.multicam.input.sources_in": "Sources" "node.multicam.input.sequence_in": "Sequence" "node.multicam.input.sequence_type_in": "Sequence Type" +"node.graphinput.name": "Entrada del grafo" +"node.graphinput.input.feed_in": "Entrada" +"node.graphoutput.name": "Salida del grafo" +"node.graphoutput.input.tex_in": "Textura" "status.ready": "Listo" "status.cache": "Caché: activada" "status.proxy.on": "Proxy: activado" diff --git a/assets/i18n/fr.yaml b/assets/i18n/fr.yaml index 6c7355a81..215870a9d 100644 --- a/assets/i18n/fr.yaml +++ b/assets/i18n/fr.yaml @@ -545,6 +545,10 @@ "node.multicam.input.sources_in": "Sources" "node.multicam.input.sequence_in": "Sequence" "node.multicam.input.sequence_type_in": "Sequence Type" +"node.graphinput.name": "Entrée du graphe" +"node.graphinput.input.feed_in": "Entrée" +"node.graphoutput.name": "Sortie du graphe" +"node.graphoutput.input.tex_in": "Texture" "status.ready": "Prêt" "status.cache": "Cache : activé" "status.proxy.on": "Proxy : activé" diff --git a/assets/i18n/ja.yaml b/assets/i18n/ja.yaml index fd8fa5f7b..b2392fb3a 100644 --- a/assets/i18n/ja.yaml +++ b/assets/i18n/ja.yaml @@ -545,6 +545,10 @@ "node.multicam.input.sources_in": "Sources" "node.multicam.input.sequence_in": "Sequence" "node.multicam.input.sequence_type_in": "Sequence Type" +"node.graphinput.name": "グラフ入力" +"node.graphinput.input.feed_in": "入力" +"node.graphoutput.name": "グラフ出力" +"node.graphoutput.input.tex_in": "テクスチャ" "status.ready": "準備完了" "status.cache": "キャッシュ:有効" "status.proxy.on": "プロキシ:オン" diff --git a/assets/i18n/pt.yaml b/assets/i18n/pt.yaml index 11faef4a5..929523924 100644 --- a/assets/i18n/pt.yaml +++ b/assets/i18n/pt.yaml @@ -545,6 +545,10 @@ "node.multicam.input.sources_in": "Sources" "node.multicam.input.sequence_in": "Sequence" "node.multicam.input.sequence_type_in": "Sequence Type" +"node.graphinput.name": "Entrada do grafo" +"node.graphinput.input.feed_in": "Entrada" +"node.graphoutput.name": "Saída do grafo" +"node.graphoutput.input.tex_in": "Textura" "status.ready": "Pronto" "status.cache": "Cache: ativado" "status.proxy.on": "Proxy: ativado" diff --git a/assets/i18n/ru.yaml b/assets/i18n/ru.yaml index fb092621e..4b69a7270 100644 --- a/assets/i18n/ru.yaml +++ b/assets/i18n/ru.yaml @@ -545,6 +545,10 @@ "node.multicam.input.sources_in": "Sources" "node.multicam.input.sequence_in": "Sequence" "node.multicam.input.sequence_type_in": "Sequence Type" +"node.graphinput.name": "Вход графа" +"node.graphinput.input.feed_in": "Вход" +"node.graphoutput.name": "Выход графа" +"node.graphoutput.input.tex_in": "Текстура" "status.ready": "Готово" "status.cache": "Кэш: включён" "status.proxy.on": "Прокси: вкл" diff --git a/assets/i18n/zh-CN.yaml b/assets/i18n/zh-CN.yaml index 18438d02a..eab0e1f02 100644 --- a/assets/i18n/zh-CN.yaml +++ b/assets/i18n/zh-CN.yaml @@ -543,6 +543,10 @@ "node.multicam.input.sources_in": "源" "node.multicam.input.sequence_in": "序列" "node.multicam.input.sequence_type_in": "序列类型" +"node.graphinput.name": "图输入" +"node.graphinput.input.feed_in": "馈入" +"node.graphoutput.name": "图输出" +"node.graphoutput.input.tex_in": "纹理" "status.ready": "就绪" "status.cache": "缓存:已启用" "status.proxy.on": "代理:开" diff --git a/crates/oak-node/src/graph.rs b/crates/oak-node/src/graph.rs index 36a46e4d5..68f907854 100644 --- a/crates/oak-node/src/graph.rs +++ b/crates/oak-node/src/graph.rs @@ -183,7 +183,14 @@ impl Graph { /// Remove a node and all its edges (C++: ~Node + set_parent(null) /// + disconnect_all side effects — see `// CPP-PARITY: node.cpp`). + /// + /// Refused (`None`) for the virtual endpoints, which every graph keeps + /// for its lifetime — removing one would strand the evaluation walk's + /// anchor ([`Graph::ensure_endpoints`]). pub fn remove_node(&mut self, id: NodeId) -> Option> { + if self.is_endpoint(id) { + return None; + } let entry = self.take_node(id)?; Some(entry.behavior) } @@ -228,6 +235,100 @@ impl Graph { ids } + /// The graph's virtual endpoint pair `(input, output)`, or `None` when + /// either is missing. Endpoints are identified by their behavior's + /// type id (see [`crate::nodes::graphendpoints`]) — no node id is ever + /// stored for them. + pub fn endpoints(&self) -> Option<(NodeId, NodeId)> { + let input = self.endpoint_of_type(crate::nodes::graphendpoints::GRAPH_INPUT_TYPE_ID)?; + let output = self.endpoint_of_type(crate::nodes::graphendpoints::GRAPH_OUTPUT_TYPE_ID)?; + Some((input, output)) + } + + /// True when `id` names one of the virtual endpoints (`false` for a + /// stale id). + pub fn is_endpoint(&self, id: NodeId) -> bool { + match self.get(id) { + Some(entry) => { + let type_id = entry.behavior.type_id(); + type_id == crate::nodes::graphendpoints::GRAPH_INPUT_TYPE_ID + || type_id == crate::nodes::graphendpoints::GRAPH_OUTPUT_TYPE_ID + } + None => false, + } + } + + /// The live node whose behavior reports `type_id`, if any. + fn endpoint_of_type(&self, type_id: &str) -> Option { + self.node_ids() + .into_iter() + .find(|&id| { + self.get(id) + .map(|e| e.behavior.type_id() == type_id) + .unwrap_or(false) + }) + } + + /// Create whichever virtual endpoints the graph is missing and wire the + /// default `input -> output` edge; returns the `(input, output)` pair. + /// + /// Idempotent and safe to call on every project load: a graph that + /// already carries both endpoints is returned untouched, and a project + /// saved before the endpoints existed gets them here (the migration + /// path — see [`crate::serializer`]). A graph with only one of the pair + /// gets the missing node added next to it. + /// + /// The default edge is only created when `output.tex_in` is free, so an + /// explicitly wired output is never overridden. + /// + /// Constructed directly rather than through the factory: these are + /// built-in graph-internal nodes and must exist even in a process where + /// the factory table has not been installed yet. + pub fn ensure_endpoints(&mut self) -> (NodeId, NodeId) { + let input = match self.endpoint_of_type(crate::nodes::graphendpoints::GRAPH_INPUT_TYPE_ID) { + Some(id) => id, + None => { + let (core, behavior) = crate::nodes::graphendpoints::create_graph_input(); + self.add_node(core, behavior) + } + }; + let output = + match self.endpoint_of_type(crate::nodes::graphendpoints::GRAPH_OUTPUT_TYPE_ID) { + Some(id) => id, + None => { + let (core, behavior) = crate::nodes::graphendpoints::create_graph_output(); + self.add_node(core, behavior) + } + }; + if self + .connected_output(output, crate::nodes::graphendpoints::GRAPH_OUTPUT_INPUT, -1) + .is_none() + { + self.connect( + input, + output, + crate::nodes::graphendpoints::GRAPH_OUTPUT_INPUT, + -1, + ) + .ok(); + } + (input, output) + } + + /// Test-only: insert an edge with none of [`Graph::connect`]'s + /// validation, so a test can build the cycle the production paths + /// reject at connect time. + #[cfg(test)] + pub(crate) fn force_connect(&mut self, from: NodeId, to: NodeId, input: &str, element: i32) { + let key = self.intern_input(input); + self.edges.insert(Edge { + from, + to, + input_key: key, + element, + }); + } + /// Connect `from`'s output to `to.input[element]` /// (C++ `Node::connect_edge` incl. cycle rejection). /// @@ -765,3 +866,192 @@ fn hash_str(input: &str) -> u64 { input.hash(&mut h); h.finish() } + +#[cfg(test)] +mod tests { + use super::*; + use crate::input::Input; + use crate::nodes::graphendpoints::{ + GRAPH_INPUT_TYPE_ID, GRAPH_OUTPUT_TYPE_ID, GRAPH_OUTPUT_INPUT, + }; + use crate::value::{NodeValue, ValueType}; + + /// Minimal non-endpoint behavior for the model tests: one connectable + /// texture input, no output logic. + struct Plain; + + impl NodeBehavior for Plain { + fn name(&self) -> &str { + "Plain" + } + + fn type_id(&self) -> &str { + "org.olivevideoeditor.Olive.plain-test" + } + + fn duplicate(&self, _core: &NodeCore) -> Option> { + Some(Box::new(Plain)) + } + } + + fn add_plain(graph: &mut Graph) -> NodeId { + let mut core = NodeCore::new(); + core.add_input(Input::new("x_in", ValueType::Texture, NodeValue::None)); + graph.add_node(core, Box::new(Plain)) + } + + #[test] + fn ensure_endpoints_creates_a_wired_pair() { + let mut graph = Graph::new(); + assert!(graph.endpoints().is_none()); + assert_eq!(graph.node_count(), 0); + + let (input, output) = graph.ensure_endpoints(); + assert_eq!(graph.endpoints(), Some((input, output))); + assert!(graph.is_endpoint(input)); + assert!(graph.is_endpoint(output)); + assert_eq!( + graph + .get(input) + .map(|e| e.behavior.type_id()) + .expect("input endpoint is live"), + GRAPH_INPUT_TYPE_ID + ); + assert_eq!( + graph + .get(output) + .map(|e| e.behavior.type_id()) + .expect("output endpoint is live"), + GRAPH_OUTPUT_TYPE_ID + ); + // Default wiring: input -> output.tex_in. + assert_eq!( + graph.connected_output(output, GRAPH_OUTPUT_INPUT, -1), + Some(input) + ); + assert_eq!(graph.node_count(), 2); + + // Idempotent: a second call reuses the same pair and edge. + assert_eq!(graph.ensure_endpoints(), (input, output)); + assert_eq!(graph.node_count(), 2); + assert_eq!(graph.output_connections(input).len(), 1); + } + + #[test] + fn ensure_endpoints_completes_a_half_pair() { + let mut graph = Graph::new(); + let (core, behavior) = crate::nodes::graphendpoints::create_graph_input(); + let input = graph.add_node(core, behavior); + // One endpoint alone is not a pair. + assert!(graph.endpoints().is_none()); + assert!(graph.is_endpoint(input)); + + let (input2, output) = graph.ensure_endpoints(); + assert_eq!(input, input2); + assert_eq!(graph.endpoints(), Some((input, output))); + assert_eq!( + graph.connected_output(output, GRAPH_OUTPUT_INPUT, -1), + Some(input) + ); + } + + #[test] + fn ensure_endpoints_keeps_an_explicitly_wired_output() { + let mut graph = Graph::new(); + let (input, output) = graph.ensure_endpoints(); + graph.disconnect(input, output, GRAPH_OUTPUT_INPUT, -1); + + let source = add_plain(&mut graph); + graph.connect(source, output, GRAPH_OUTPUT_INPUT, -1).unwrap(); + + // No default edge is forced over the explicit one. + assert_eq!(graph.ensure_endpoints(), (input, output)); + assert_eq!( + graph.connected_output(output, GRAPH_OUTPUT_INPUT, -1), + Some(source) + ); + } + + #[test] + fn remove_node_refuses_endpoints() { + let mut graph = Graph::new(); + let (input, output) = graph.ensure_endpoints(); + let plain = add_plain(&mut graph); + + assert!(graph.remove_node(input).is_none()); + assert!(graph.remove_node(output).is_none()); + assert!(graph.is_valid(input) && graph.is_valid(output)); + assert_eq!(graph.endpoints(), Some((input, output))); + assert_eq!( + graph.connected_output(output, GRAPH_OUTPUT_INPUT, -1), + Some(input) + ); + + // Non-endpoint nodes still go away, and a removed id is not an + // endpoint (nor is a stale one). + assert!(graph.remove_node(plain).is_some()); + assert!(!graph.is_valid(plain)); + assert!(!graph.is_endpoint(plain)); + assert!(!graph.is_endpoint(NodeId::INVALID)); + assert!(graph.remove_node(NodeId::INVALID).is_none()); + } + + #[test] + fn duplicate_refuses_endpoints_and_dependency_copies_propagate() { + let mut graph = Graph::new(); + let (input, output) = graph.ensure_endpoints(); + + assert!(graph + .copy_node_and_dependency_graph_minus_items(input) + .is_none()); + assert!(graph + .copy_node_and_dependency_graph_minus_items(output) + .is_none()); + + // A node whose dependency graph contains an endpoint cannot be + // copied either: the recursion reaches the endpoint's + // `duplicate` -> None. + let plain = add_plain(&mut graph); + graph.connect(input, plain, "x_in", -1).unwrap(); + assert!(graph + .copy_node_and_dependency_graph_minus_items(plain) + .is_none()); + } + + #[test] + fn dependency_copy_of_an_endpoint_free_subgraph_still_works() { + let mut graph = Graph::new(); + let a = add_plain(&mut graph); + let b = add_plain(&mut graph); + graph.connect(a, b, "x_in", -1).unwrap(); + + let (copy, map) = graph + .copy_node_and_dependency_graph_minus_items(b) + .expect("an endpoint-free chain copies"); + assert_ne!(copy, b); + assert_eq!(map.get(&b), Some(©)); + let a_copy = map.get(&a).copied().expect("a is copied"); + assert_ne!(a_copy, a); + assert_eq!(graph.connected_output(copy, "x_in", -1), Some(a_copy)); + } + + #[test] + fn force_connect_builds_the_cycle_connect_rejects() { + let mut graph = Graph::new(); + let a = add_plain(&mut graph); + let b = add_plain(&mut graph); + graph.connect(a, b, "x_in", -1).unwrap(); + assert_eq!( + graph.connect(b, a, "x_in", -1), + Err(crate::error::Error::State) + ); + + graph.force_connect(b, a, "x_in", -1); + assert!(graph.reaches(a, b)); + assert!(graph.reaches(b, a)); + assert_eq!( + graph.input_connections(a), + vec![(b, "x_in".to_string(), -1)] + ); + } +} diff --git a/crates/oak-node/src/nodes/graphendpoints.rs b/crates/oak-node/src/nodes/graphendpoints.rs new file mode 100644 index 000000000..493f7fca5 --- /dev/null +++ b/crates/oak-node/src/nodes/graphendpoints.rs @@ -0,0 +1,396 @@ +// Oak Video Editor - Non-Linear Video Editor +// Copyright (C) 2026 Oak Team +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU General Public License for more details. +// +// You should have received a copy of the GNU General Public License +// along with this program. If not, see . + +//! The graph's virtual endpoints: `GraphInput` / `GraphOutput`. +//! +//! Oak-only design with no C++ counterpart: upstream Olive's traversal +//! walks back from the viewer output node and has no endpoint pair (see +//! `docs/zh/plans/render-pipeline-threads.md` §3.8). Every project graph +//! carries exactly one pair, created by `Graph::ensure_endpoints` +//! (default-wired `input -> output`, and only that pair — the graph +//! refuses to remove or copy them). `GraphInput` is the start of the +//! evaluation walk ([`crate::traverser::Traverser::eval_graph_bfs`]); +//! `GraphOutput` is where every branch converges and its value is the +//! frame. +//! +//! `GraphInput`'s `value()` forwards whatever its input row carries — +//! the node itself produces no pixels. `GraphOutput`'s `value()` hands +//! its `tex_in` row value back out as the node's own output, so a +//! consumer reads the frame from the output node's table. +//! +//! Deviation from the plan's literal wording ("only an output port"): the +//! input endpoint also declares one connectable texture input, +//! `feed_in`. Footage and generator nodes have no connectable inputs at +//! all (their media input is `NOT_CONNECTABLE`), so without this port a +//! single-clip graph could not be wired into the walk: the walk's live +//! set is anchored at `GraphInput`, and a source with no edge into that +//! anchor would be pruned. The sequence renderer feeds its composite +//! through the same port. The "only output ports" half of the plan +//! sentence still holds literally: the input endpoint's only output port +//! is `tex_out`. + +use crate::factory::NodeMeta; +use crate::node::{Category, NodeBehavior, NodeCore}; +use crate::value::{NodeValue, NodeValueRow, NodeValueTable, ValueType}; + +/// Stable type id of the virtual graph input endpoint. Graph model code +/// identifies the endpoint by this id (never by storing a node id). +pub const GRAPH_INPUT_TYPE_ID: &str = "org.olivevideoeditor.Olive.graphinput"; + +/// Stable type id of the virtual graph output endpoint (`Graph::endpoints` +/// and `Graph::is_endpoint` match on it). +pub const GRAPH_OUTPUT_TYPE_ID: &str = "org.olivevideoeditor.Olive.graphoutput"; + +/// The input endpoint's texture input id: the graph's raw data entrance. +/// A footage, generator, or sequence-composite node's output feeds the +/// graph through here. Type: texture; flags: not-keyframable (see the +/// module doc for why this port exists at all). +pub const GRAPH_INPUT_FEED_INPUT: &str = "feed_in"; + +/// The input endpoint's output port id (the evaluation walk's root +/// output; the sequence renderer and the node editor use it as the +/// input node's only outgoing port). +pub const GRAPH_INPUT_OUTPUT: &str = "tex_out"; + +/// The output endpoint's texture input id: every live branch converges +/// here and the node's own value is this input's value. Type: texture; +/// flags: not-keyframable. +pub const GRAPH_OUTPUT_INPUT: &str = "tex_in"; + +/// The virtual graph input node. Has no member fields. +pub struct GraphInputNode; + +/// The virtual graph output node. Has no member fields. +pub struct GraphOutputNode; + +impl NodeBehavior for GraphInputNode { + /// Human-readable name (shown as the node's fixed title). + fn name(&self) -> &str { + "Graph Input" + } + + /// Stable type id. + fn type_id(&self) -> &str { + GRAPH_INPUT_TYPE_ID + } + + /// Categories. The pair is never in a create menu (the constructor + /// sets `DONT_SHOW_IN_CREATE_MENU`); the category only groups it in + /// listings that ignore that flag. + fn categories(&self) -> &[Category] { + &[Category::Input] + } + + /// Description. + fn description(&self) -> &str { + "The graph's input endpoint: the evaluation walk starts here and its row is forwarded downstream." + } + + /// Localized input names: `feed_in` -> "Feed". + fn input_name<'a>(&self, id: &'a str) -> &'a str { + match id { + GRAPH_INPUT_FEED_INPUT => "Feed", + _ => id, + } + } + + /// Forward the input row: every declared input that carries a + /// non-`None` value is pushed under its declared type, so downstream + /// nodes see exactly what was fed in. The node generates no pixels + /// of its own (C++ `Node::value` counterpart absent — Oak-only node). + fn value( + &self, + core: &NodeCore, + inputs: &NodeValueRow, + _time: oak_core::Rational, + table: &mut NodeValueTable, + ) { + for input in &core.inputs { + let Some(value) = inputs.get(&input.id) else { + continue; + }; + if matches!(value, NodeValue::None) { + continue; + } + // `NodeValue::clone` addrefs texture handles so the table owns + // its own reference (released on drop). + table.push(input.value_type, value.clone(), None); + } + } + + /// The endpoint pair is fixed: copying either node is refused (the + /// graph model also refuses `remove_node`). + fn duplicate(&self, _core: &NodeCore) -> Option> { + None + } +} + +impl NodeBehavior for GraphOutputNode { + /// Human-readable name (shown as the node's fixed title). + fn name(&self) -> &str { + "Graph Output" + } + + /// Stable type id. + fn type_id(&self) -> &str { + GRAPH_OUTPUT_TYPE_ID + } + + /// Categories. See [`GraphInputNode::categories`]. + fn categories(&self) -> &[Category] { + &[Category::Output] + } + + /// Description. + fn description(&self) -> &str { + "The graph's output endpoint: every live branch converges here; the node's value is the frame." + } + + /// Localized input names: `tex_in` -> "Texture". + fn input_name<'a>(&self, id: &'a str) -> &'a str { + match id { + GRAPH_OUTPUT_INPUT => "Texture", + _ => id, + } + } + + /// Evaluate outputs: push the incoming texture as this node's own + /// value, so the walk's result is read from the output endpoint's + /// table (nothing incoming -> nothing pushed). The sibling-branch + /// convergence itself happens in the walk (Kahn in-degree), not here. + fn value( + &self, + core: &NodeCore, + inputs: &NodeValueRow, + _time: oak_core::Rational, + table: &mut NodeValueTable, + ) { + let Some(value) = inputs.get(GRAPH_OUTPUT_INPUT) else { + return; + }; + if matches!(value, NodeValue::None) { + return; + } + let Some(data_type) = core.input_data_type(GRAPH_OUTPUT_INPUT) else { + return; + }; + table.push(data_type, value.clone(), None); + } + + /// The endpoint pair is fixed: see [`GraphInputNode::duplicate`]. + fn duplicate(&self, _core: &NodeCore) -> Option> { + None + } +} + +/// Constructor: the standard `enabled_in`, one connectable, +/// non-keyframable `feed_in` texture input (default `None` — nothing is +/// fed in until the project wires a source), and the create-menu hiding +/// flag. No effect flags: the node is not addable, not an effect input, +/// and not an item. +pub fn create_graph_input() -> (NodeCore, Box) { + let mut core = NodeCore::new(); + let mut feed = crate::input::Input::new( + GRAPH_INPUT_FEED_INPUT, + ValueType::Texture, + NodeValue::None, + ); + feed.flags |= crate::input::flags::NOT_KEYFRAMABLE; + core.add_input(feed); + core.flags |= crate::node::flags::DONT_SHOW_IN_CREATE_MENU; + (core, Box::new(GraphInputNode)) +} + +/// Constructor: the standard `enabled_in`, one connectable, +/// non-keyframable `tex_in` texture input (default `None`), and the +/// create-menu hiding flag. See [`create_graph_input`]. +pub fn create_graph_output() -> (NodeCore, Box) { + let mut core = NodeCore::new(); + let mut tex = crate::input::Input::new( + GRAPH_OUTPUT_INPUT, + ValueType::Texture, + NodeValue::None, + ); + tex.flags |= crate::input::flags::NOT_KEYFRAMABLE; + core.add_input(tex); + core.flags |= crate::node::flags::DONT_SHOW_IN_CREATE_MENU; + (core, Box::new(GraphOutputNode)) +} + +/// Register both endpoint types with the factory: the graph model and +/// the project loader construct them by type id, so serialization can +/// rebuild a loaded graph's missing endpoints. Not in the C++ menu +/// order (no C++ counterpart) — the entries are appended after the +/// built-ins, and the hiding flag keeps them out of every create menu. +pub fn register(meta: &mut Vec) { + meta.push(NodeMeta { + type_id: GRAPH_INPUT_TYPE_ID, + name: "Graph Input", + categories: &[Category::Input], + create: create_graph_input, + }); + meta.push(NodeMeta { + type_id: GRAPH_OUTPUT_TYPE_ID, + name: "Graph Output", + categories: &[Category::Output], + create: create_graph_output, + }); +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::factory::Factory; + + #[test] + fn names_and_type_ids() { + let (_, input) = create_graph_input(); + assert_eq!(input.name(), "Graph Input"); + assert_eq!(input.type_id(), GRAPH_INPUT_TYPE_ID); + assert_eq!(input.categories(), &[Category::Input]); + let (_, output) = create_graph_output(); + assert_eq!(output.name(), "Graph Output"); + assert_eq!(output.type_id(), GRAPH_OUTPUT_TYPE_ID); + assert_eq!(output.categories(), &[Category::Output]); + } + + #[test] + fn create_sets_ports_and_hiding_flag() { + use crate::input::flags as input_flags; + use crate::node::flags as node_flags; + + let (core, _) = create_graph_input(); + let feed = core.get_input(GRAPH_INPUT_FEED_INPUT).expect("feed_in"); + assert_eq!(feed.value_type, ValueType::Texture); + assert_eq!(feed.default, NodeValue::None); + assert_eq!(feed.flags & input_flags::NOT_KEYFRAMABLE, input_flags::NOT_KEYFRAMABLE); + assert!(feed.is_connectable(), "the data entrance accepts edges"); + assert!(core.get_input(crate::node::ENABLED_INPUT).is_some()); + assert_ne!(core.flags & node_flags::DONT_SHOW_IN_CREATE_MENU, 0); + // Not an effect, not an item, no effect input. + assert_eq!(core.flags & node_flags::VIDEO_EFFECT, 0); + assert_eq!(core.flags & node_flags::AUDIO_EFFECT, 0); + assert_eq!(core.flags & node_flags::IS_ITEM, 0); + assert!(core.effect_input.is_empty()); + + let (core, _) = create_graph_output(); + let tex = core.get_input(GRAPH_OUTPUT_INPUT).expect("tex_in"); + assert_eq!(tex.value_type, ValueType::Texture); + assert_eq!(tex.default, NodeValue::None); + assert!(tex.is_connectable()); + assert_ne!(core.flags & node_flags::DONT_SHOW_IN_CREATE_MENU, 0); + assert_eq!(core.flags & node_flags::VIDEO_EFFECT, 0); + } + + #[test] + fn duplicate_is_refused() { + let (core, input) = create_graph_input(); + assert!(input.duplicate(&core).is_none()); + let (core, output) = create_graph_output(); + assert!(output.duplicate(&core).is_none()); + } + + #[test] + fn input_names_and_enabled_fallthrough() { + let (_, input) = create_graph_input(); + assert_eq!(input.input_name(GRAPH_INPUT_FEED_INPUT), "Feed"); + // The raw id (not the default "Enabled") so no pack entry is + // required for the structural input (the param view hides it). + assert_eq!(input.input_name(crate::node::ENABLED_INPUT), "enabled_in"); + let (_, output) = create_graph_output(); + assert_eq!(output.input_name(GRAPH_OUTPUT_INPUT), "Texture"); + assert_eq!(output.input_name("other_in"), "other_in"); + } + + #[test] + fn factory_resolves_both_types() { + let (core, behavior) = Factory::global() + .create_any(GRAPH_INPUT_TYPE_ID) + .expect("the factory registers the graph input endpoint"); + assert_eq!( + core.flags & crate::node::flags::DONT_SHOW_IN_CREATE_MENU, + crate::node::flags::DONT_SHOW_IN_CREATE_MENU + ); + assert_eq!(behavior.type_id(), GRAPH_INPUT_TYPE_ID); + let (core, behavior) = Factory::global() + .create_any(GRAPH_OUTPUT_TYPE_ID) + .expect("the factory registers the graph output endpoint"); + assert_eq!( + core.flags & crate::node::flags::DONT_SHOW_IN_CREATE_MENU, + crate::node::flags::DONT_SHOW_IN_CREATE_MENU + ); + assert_eq!(behavior.type_id(), GRAPH_OUTPUT_TYPE_ID); + } + + #[test] + fn graph_input_forwards_the_row() { + let (core, behavior) = create_graph_input(); + let tex = NodeValue::Texture(crate::handle::CHandle::null()); + let row = NodeValueRow::from([ + (crate::node::ENABLED_INPUT.to_string(), NodeValue::Boolean(true)), + (GRAPH_INPUT_FEED_INPUT.to_string(), tex), + ]); + let mut table = NodeValueTable::default(); + behavior.value(&core, &row, oak_core::Rational::new(0, 1), &mut table); + assert_eq!(table.count(), 2); + assert_eq!(table.get(ValueType::Boolean), Some(&NodeValue::Boolean(true))); + assert!(matches!(table.get(ValueType::Texture), Some(NodeValue::Texture(_)))); + } + + #[test] + fn graph_input_skips_a_missing_or_none_row_value() { + let (core, behavior) = create_graph_input(); + let mut table = NodeValueTable::default(); + behavior.value( + &core, + &NodeValueRow::default(), + oak_core::Rational::new(0, 1), + &mut table, + ); + assert!(table.is_empty(), "no row entries -> nothing forwarded"); + + let row = NodeValueRow::from([( + GRAPH_INPUT_FEED_INPUT.to_string(), + NodeValue::None, + )]); + behavior.value(&core, &row, oak_core::Rational::new(0, 1), &mut table); + assert!(table.is_empty(), "a None value is not forwarded"); + } + + #[test] + fn graph_output_pushes_its_texture() { + let (core, behavior) = create_graph_output(); + let tex = NodeValue::Texture(crate::handle::CHandle::null()); + let row = NodeValueRow::from([(GRAPH_OUTPUT_INPUT.to_string(), tex)]); + let mut table = NodeValueTable::default(); + behavior.value(&core, &row, oak_core::Rational::new(0, 1), &mut table); + let handle = match table.get(ValueType::Texture) { + Some(NodeValue::Texture(h)) => *h, + other => panic!("texture expected, got {other:?}"), + }; + assert!(handle.ctx.is_null()); + + // Nothing incoming -> nothing pushed. + let mut empty = NodeValueTable::default(); + behavior.value( + &core, + &NodeValueRow::default(), + oak_core::Rational::new(0, 1), + &mut empty, + ); + assert!(empty.is_empty()); + } +} diff --git a/crates/oak-node/src/nodes/mod.rs b/crates/oak-node/src/nodes/mod.rs index d35e75e8b..23b0a489c 100644 --- a/crates/oak-node/src/nodes/mod.rs +++ b/crates/oak-node/src/nodes/mod.rs @@ -30,6 +30,7 @@ mod displaytransform; mod dropshadowfilter; mod flipdistortnode; mod generatorwithmerge; +pub mod graphendpoints; pub mod group; mod mask; mod math; @@ -190,6 +191,13 @@ pub fn register_all() { ramp::register(&mut meta); multicamnode::register(&mut meta); + // Oak-only virtual graph endpoints (see + // [`graphendpoints`](crate::nodes::graphendpoints)). No C++ menu + // entry exists; they are appended after the C++ order ends and are + // hidden from every create menu (`DONT_SHOW_IN_CREATE_MENU`), so the + // only observable effect on the factory table is two extra entries. + graphendpoints::register(&mut meta); + // OpenFX plugins have no static type ids (C++ // `factory.cpp::register_plugin_nodes`); the registration call is a // no-op placeholder for the oakplugin bridge's runtime discovery. diff --git a/crates/oak-node/src/nodes/textbackend.rs b/crates/oak-node/src/nodes/textbackend.rs index 4eb4ceacb..095e52456 100644 --- a/crates/oak-node/src/nodes/textbackend.rs +++ b/crates/oak-node/src/nodes/textbackend.rs @@ -163,18 +163,24 @@ static MEASURE: std::sync::Mutex> = std::sync::Mutex: /// Installed render hook (C++ global `g_text_render_backend`). static RENDER: std::sync::Mutex> = std::sync::Mutex::new(None); +/// Serializes every test that installs the process-global text backends: +/// the tests below and `textv3`'s installer tests share it, so none of +/// them observes another's install. +#[cfg(test)] +pub(crate) static TEST_BACKEND_LOCK: std::sync::Mutex<()> = std::sync::Mutex::new(()); + #[cfg(test)] mod tests { use super::*; - // The two tests below share the process-global backend statics; a - // lock serializes them so `backend_hooks_default_none` cannot observe - // the hooks installed by `backend_hooks_install_and_query`. - static LOCK: std::sync::Mutex<()> = std::sync::Mutex::new(()); + // The two tests below share the process-global backend statics; the + // shared test lock also serializes them against `textv3`'s installer + // tests, so `backend_hooks_default_none` cannot observe the hooks + // installed by `backend_hooks_install_and_query`. #[test] fn backend_hooks_default_none() { - let _guard = LOCK.lock().unwrap(); + let _guard = TEST_BACKEND_LOCK.lock().unwrap(); set_text_backends(None, None); assert_eq!(text_measure_backend(), None); assert_eq!(text_render_backend(), None); @@ -182,7 +188,7 @@ mod tests { #[test] fn backend_hooks_install_and_query() { - let _guard = LOCK.lock().unwrap(); + let _guard = TEST_BACKEND_LOCK.lock().unwrap(); fn measure(_r: &TextLayoutRequest) -> TextLayoutSize { TextLayoutSize { width: 12.0, diff --git a/crates/oak-node/src/nodes/textv3.rs b/crates/oak-node/src/nodes/textv3.rs index c988ce37e..bad4f510e 100644 --- a/crates/oak-node/src/nodes/textv3.rs +++ b/crates/oak-node/src/nodes/textv3.rs @@ -1741,11 +1741,10 @@ mod tests { ); } - /// Serializes the tests that install a process-global text backend. - /// (The `textbackend` module's own tests use a different lock, so they - /// are not mutually excluded — same exposure as the pre-existing - /// `measure_without_backend_returns_zero_size`.) - static BACKEND_LOCK: std::sync::Mutex<()> = std::sync::Mutex::new(()); + /// Serializes the tests that install a process-global text backend; + /// shared with the `textbackend` module's own tests, so no test + /// observes another module's install. + use crate::nodes::textbackend::TEST_BACKEND_LOCK as BACKEND_LOCK; /// Measure hook for tests that only need "a backend is installed". fn noop_measure(_req: &TextLayoutRequest) -> TextLayoutSize { @@ -1802,6 +1801,8 @@ mod tests { #[test] fn value_pushes_job_when_text_nonempty() { + let _guard = BACKEND_LOCK.lock().unwrap(); + crate::nodes::textbackend::set_text_backends(None, None); let (core, behavior) = create(); let mut row = NodeValueRow::default(); row.insert( @@ -1819,6 +1820,8 @@ mod tests { #[test] fn value_expands_args_from_row() { + let _guard = BACKEND_LOCK.lock().unwrap(); + crate::nodes::textbackend::set_text_backends(None, None); let (core, behavior) = create(); let mut row = NodeValueRow::default(); row.insert( @@ -1860,8 +1863,8 @@ mod tests { let (core, behavior) = create(); let mut row = NodeValueRow::default(); // Both text inputs are emptied explicitly: which one `value` reads - // depends on the process-global backend state, which the tests that - // install one (own lock, and `textbackend`'s) change concurrently. + // depends on the process-global backend state, which the installer + // tests change and restore concurrently. row.insert(PLAIN_TEXT_INPUT.to_string(), NodeValue::Text(String::new())); row.insert(TEXT_INPUT.to_string(), NodeValue::Text(String::new())); let mut table = NodeValueTable::default(); diff --git a/crates/oak-node/src/project.rs b/crates/oak-node/src/project.rs index 65a16bbd8..04b73d480 100644 --- a/crates/oak-node/src/project.rs +++ b/crates/oak-node/src/project.rs @@ -119,6 +119,10 @@ impl Project { self.root = id; self.settings .insert(SETTING_ROOT.to_string(), id.identity().to_string()); + // Every project graph carries the virtual endpoint pair (see + // docs/zh/plans/render-pipeline-threads.md §3.8); created last so + // the root folder keeps the first arena slot. + self.graph.ensure_endpoints(); Ok(()) } diff --git a/crates/oak-node/src/serializer.rs b/crates/oak-node/src/serializer.rs index 3593ad534..c9dad8d4e 100644 --- a/crates/oak-node/src/serializer.rs +++ b/crates/oak-node/src/serializer.rs @@ -662,6 +662,12 @@ fn load_project_body( // reattach each child to its bin folder. resolve_folder_children(&mut project.graph, &id_map); + // Migration for projects saved before the endpoints existed (or whose + // file omitted them): create whatever is missing and default-wire the + // pair. A file that already carries both endpoints — and possibly a + // hand-wired output — is left untouched. + project.graph.ensure_endpoints(); + Ok(id_map) } diff --git a/crates/oak-node/src/traverser.rs b/crates/oak-node/src/traverser.rs index c19d9f5ee..470f76854 100644 --- a/crates/oak-node/src/traverser.rs +++ b/crates/oak-node/src/traverser.rs @@ -37,7 +37,7 @@ //! (C++ `ProcessInputElement` → `GetValueAtTime`). //! `// CPP-PARITY: src/node/src/traverser.cpp`. -use std::collections::{HashMap, HashSet}; +use std::collections::{BTreeSet, HashMap, HashSet}; use oak_core::{Rational, TimeRange}; @@ -133,55 +133,126 @@ impl Traverser { // Per-pass memo: (node, time) -> evaluated output table. A shared // upstream evaluates once per requested time (C++ value_cache_). let mut cache: HashMap<(NodeId, Rational), NodeValueTable> = HashMap::new(); - let mut queued: HashSet<(NodeId, Rational)> = HashSet::new(); - let mut stack: Vec = vec![Frame::Enter(request.root, request.time)]; - queued.insert((request.root, request.time)); + walk_dfs(graph, &mut cache, request.root, request.time, hooks)?; + Ok(cache + .remove(&(request.root, request.time)) + .unwrap_or_default()) + } - while let Some(frame) = stack.pop() { + /// Evaluate the graph as one Kahn-order sweep of the *live set* and + /// return the [`GraphOutput`](crate::nodes::graphendpoints) value — + /// the frame (C++ has no counterpart; the C++ traversal starts at the + /// consumer and walks backwards, Oak starts at the graph's input + /// endpoint and converges on its output endpoint). + /// + /// The live set is `(downstream of the input ∨ feeding the input) ∧ + /// (upstream of the output)`: the input's forward cone plus the nodes + /// that reach the input (a sequence feeds the graph through + /// `GraphInput.feed_in`, which puts it in that feeder cone), + /// intersected with the output's backward cone. Isolated nodes and + /// branches that never reach the output are never queued. A + /// connection coming from *outside* the live set leaves `None` in the + /// consuming row (same as a missing cache entry in the DFS walk). + /// + /// Each live node is dequeued once, in ascending [`NodeId`] order + /// among the ready nodes, with the DFS walk's per-node semantics + /// (`GenerateRowValue` → `value()` → [`RenderHooks::resolve`]), and + /// the resolved table is what its downstream consumers see. The + /// ascending-id order is the only deterministic order available: + /// `Graph::edges` is a `BTreeSet` keyed by `(from, to, input, + /// element)`, so the order in which connections were made is not + /// stored and cannot be recovered. + /// + /// Time semantics are single-time: `time` is the frame being + /// rendered, and the consuming node's `input_time_adjustment` applies + /// as usual — an upstream at a different time is evaluated through + /// the DFS memo (exactly what [`Traverser::evaluate`] would do), + /// while same-time upstreams are the sweep's own job. Multi-time + /// memoization across a range is M1+ work. + /// + /// Errors: `NotFound` when either endpoint is missing; `Failed` when + /// the live set cannot be ordered (the message names a cycle found + /// in it) or when the output is not reachable from the input; `State` + /// on cancellation. + pub fn eval_graph_bfs( + &mut self, + graph: &Graph, + time: Rational, + hooks: &mut dyn RenderHooks, + ) -> crate::error::Result { + use crate::error::Error; + let (input, output) = graph.endpoints().ok_or(Error::NotFound)?; + let live = live_nodes(graph, input, output); + + // In-degree over the live-induced subgraph only: an edge whose + // source lies outside the live set must not keep its target from + // ever becoming ready. + let mut indegree: HashMap = HashMap::new(); + for node in &live { + let n = graph + .input_connections(*node) + .iter() + .filter(|(from, _, _)| live.contains(from)) + .count(); + indegree.insert(*node, n); + } + let mut ready: BTreeSet = indegree + .iter() + .filter(|(_, n)| **n == 0) + .map(|(node, _)| *node) + .collect(); + + let mut cache: HashMap<(NodeId, Rational), NodeValueTable> = HashMap::new(); + let mut done = 0usize; + while let Some(node) = ready.iter().next().copied() { + ready.remove(&node); if hooks.is_cancelled() { return Err(Error::State); } - match frame { - Frame::Enter(node, time) => { - if cache.contains_key(&(node, time)) { - continue; - } - let Some(entry) = graph.get(node) else { - continue; - }; - stack.push(Frame::Exit(node, time)); - // Queue every connected upstream at its adjusted time. - for (from, input, element) in graph.input_connections(node) { - let from = entry - .behavior - .connected_render_output(&entry.core, &input, element) - .unwrap_or(from); - let adjusted = adjusted_time(entry, &input, element, time); - let key = (from, adjusted); - if !cache.contains_key(&key) && queued.insert(key) { - stack.push(Frame::Enter(from, adjusted)); - } - } + let Some(entry) = graph.get(node) else { + continue; + }; + done += 1; + // Time-shifted upstreams are not part of this sweep; pull them + // through the DFS memo. A node may already own a table at + // `time` (a pull deeper down evaluated it out of order) — in + // that case it is not evaluated (and resolved) twice. + pull_adjusted_upstreams(graph, &mut cache, entry, node, time, hooks)?; + if !cache.contains_key(&(node, time)) { + let row = build_row(graph, &cache, entry, node, time); + let mut table = NodeValueTable::default(); + entry.behavior.value(&entry.core, &row, time, &mut table); + hooks.resolve(node, &row, &mut table); + cache.insert((node, time), table); + } + for (to, _, _) in graph.output_connections(node) { + if !live.contains(&to) { + continue; } - Frame::Exit(node, time) => { - if cache.contains_key(&(node, time)) { - continue; + if let Some(n) = indegree.get_mut(&to) { + *n -= 1; + if *n == 0 { + ready.insert(to); } - let Some(entry) = graph.get(node) else { - continue; - }; - let row = build_row(graph, &cache, entry, node, time); - let mut table = NodeValueTable::default(); - entry.behavior.value(&entry.core, &row, time, &mut table); - hooks.resolve(node, &row, &mut table); - cache.insert((node, time), table); } } } - Ok(cache - .remove(&(request.root, request.time)) - .unwrap_or_default()) + if done < live.len() { + let cycle = find_cycle(graph, &indegree); + return Err(Error::Failed(format!("graph contains a cycle: {cycle:?}"))); + } + + let Some(table) = cache.remove(&(output, time)) else { + return Err(Error::Failed( + "graph output is not reachable from the graph input".to_string(), + )); + }; + Ok(table + .get(ValueType::Texture) + .cloned() + .or_else(|| table.rows().last().map(|(_, value, _)| value.clone())) + .unwrap_or(NodeValue::None)) } /// Invalidate walk: mark downstream caches dirty after an input @@ -208,6 +279,171 @@ impl Default for Traverser { } } +/// Explicit-stack DFS from `root` at `time` (the C++ recursion, minus +/// the recursion), memoizing every evaluated `(node, time)` table into +/// `cache`. `Enter` queues the connected upstreams at their adjusted +/// times, `Exit` builds the row and runs `GenerateRowValue` → +/// `value()` → [`RenderHooks::resolve`]; a node is entered at most once +/// per time (`queued` is the DFS gray set, `cache` the black set). +/// +/// Shared by [`Traverser::evaluate`] (whole walk) and +/// [`Traverser::eval_graph_bfs`] (time-shifted upstream pulls). +/// +/// Errors: `State` on cancellation. +fn walk_dfs( + graph: &Graph, + cache: &mut HashMap<(NodeId, Rational), NodeValueTable>, + root: NodeId, + time: Rational, + hooks: &mut dyn RenderHooks, +) -> crate::error::Result<()> { + use crate::error::Error; + let mut queued: HashSet<(NodeId, Rational)> = HashSet::new(); + let mut stack: Vec = vec![Frame::Enter(root, time)]; + queued.insert((root, time)); + + while let Some(frame) = stack.pop() { + if hooks.is_cancelled() { + return Err(Error::State); + } + match frame { + Frame::Enter(node, time) => { + if cache.contains_key(&(node, time)) { + continue; + } + let Some(entry) = graph.get(node) else { + continue; + }; + stack.push(Frame::Exit(node, time)); + // Queue every connected upstream at its adjusted time. + for (from, input, element) in graph.input_connections(node) { + let from = entry + .behavior + .connected_render_output(&entry.core, &input, element) + .unwrap_or(from); + let adjusted = adjusted_time(entry, &input, element, time); + let key = (from, adjusted); + if !cache.contains_key(&key) && queued.insert(key) { + stack.push(Frame::Enter(from, adjusted)); + } + } + } + Frame::Exit(node, time) => { + if cache.contains_key(&(node, time)) { + continue; + } + let Some(entry) = graph.get(node) else { + continue; + }; + let row = build_row(graph, cache, entry, node, time); + let mut table = NodeValueTable::default(); + entry.behavior.value(&entry.core, &row, time, &mut table); + hooks.resolve(node, &row, &mut table); + cache.insert((node, time), table); + } + } + } + Ok(()) +} + +/// Pull the upstreams of `node` that the sweep will not visit itself: +/// connections whose `input_time_adjustment` maps `time` to a different +/// time are evaluated on the spot through the DFS memo (the very walk +/// [`Traverser::evaluate`] performs, so those values carry the same +/// semantics), and their tables land in `cache` for [`build_row`]. +/// +/// Errors: `State` on cancellation (propagated from the pull). +fn pull_adjusted_upstreams( + graph: &Graph, + cache: &mut HashMap<(NodeId, Rational), NodeValueTable>, + entry: &crate::graph::NodeEntry, + node: NodeId, + time: Rational, + hooks: &mut dyn RenderHooks, +) -> crate::error::Result<()> { + for (from, input, element) in graph.input_connections(node) { + let from = entry + .behavior + .connected_render_output(&entry.core, &input, element) + .unwrap_or(from); + let adjusted = adjusted_time(entry, &input, element, time); + if adjusted == time || !graph.is_valid(from) { + continue; + } + if !cache.contains_key(&(from, adjusted)) { + walk_dfs(graph, cache, from, adjusted, hooks)?; + } + } + Ok(()) +} + +/// Every node reachable from `root` (including `root`) walking +/// downstream, or — with `upstream` — every node that can reach `root`. +fn reachable(graph: &Graph, root: NodeId, upstream: bool) -> HashSet { + let mut seen: HashSet = HashSet::new(); + let mut stack: Vec = vec![root]; + while let Some(node) = stack.pop() { + if !seen.insert(node) { + continue; + } + if upstream { + stack.extend(graph.upstream(node)); + } else { + stack.extend(graph.downstream(node)); + } + } + seen +} + +/// The live set of the endpoint-to-endpoint sweep: the input's forward +/// cone plus its feeder cone (nodes that reach the input — how a +/// sequence feeds `GraphInput.feed_in`), intersected with the output's +/// backward cone. See [`Traverser::eval_graph_bfs`]. +fn live_nodes(graph: &Graph, input: NodeId, output: NodeId) -> HashSet { + let mut live = reachable(graph, input, false); + live.extend(reachable(graph, input, true)); + let reaches_output = reachable(graph, output, true); + live.retain(|node| reaches_output.contains(node)); + live +} + +/// A cycle inside the nodes the sweep could not order: residual nodes +/// (positive in-degree left) walked upstream, always taking the +/// smallest-id residual feeder, until a node repeats — the repeated +/// suffix is the cycle. Deterministic; empty when there is no residual +/// node. +fn find_cycle(graph: &Graph, indegree: &HashMap) -> Vec { + let residual: BTreeSet = indegree + .iter() + .filter(|(_, n)| **n > 0) + .map(|(node, _)| *node) + .collect(); + let mut path: Vec = Vec::new(); + let mut seen: HashMap = HashMap::new(); + let Some(start) = residual.iter().next().copied() else { + return path; + }; + let mut node = start; + loop { + if let Some(&at) = seen.get(&node) { + return path.split_off(at); + } + seen.insert(node, path.len()); + path.push(node); + // Every residual node has a residual feeder: in-degree was only + // counted over live edges, and an unprocessed feeder keeps its + // own positive in-degree. + match graph + .upstream(node) + .into_iter() + .find(|from| residual.contains(from)) + { + Some(from) => node = from, + None => return path, + } + } +} + /// The consuming node's time adjustment for `input` (C++ /// `Node::InputTimeAdjustment` with `traverse = true`): clips map /// sequence time to media time, tracks clamp to the covering block. The @@ -323,3 +559,470 @@ pub struct ValueDatabase { /// Rows keyed by node input id. pub rows: Vec<(String, Vec<(ValueType, NodeValue)>)>, } + +#[cfg(test)] +mod tests { + use super::*; + use crate::error::Error; + use crate::handle::CHandle; + use crate::input::Input; + use crate::node::{NodeBehavior, NodeCore}; + use crate::nodes::graphendpoints::{GRAPH_INPUT_FEED_INPUT, GRAPH_OUTPUT_INPUT}; + use std::sync::atomic::{AtomicUsize, Ordering}; + use std::sync::{Arc, Mutex}; + + /// What a [`TestNode`] pushes to its own output table. + enum Emit { + /// Nothing at all. + Nothing, + /// A constant float. + Float(f64), + /// The time the node was evaluated at. + Time, + /// A null texture handle. + Texture, + } + + /// Counting stand-in for a real node: emits on demand so a test can + /// observe evaluation counts, dequeue order, and input rows. + struct TestNode { + emit: Emit, + calls: Arc, + } + + impl NodeBehavior for TestNode { + fn name(&self) -> &str { + "Test Node" + } + + fn type_id(&self) -> &str { + "org.olivevideoeditor.Olive.test-bfs-node" + } + + fn value( + &self, + _core: &NodeCore, + _inputs: &NodeValueRow, + time: Rational, + table: &mut NodeValueTable, + ) { + self.calls.fetch_add(1, Ordering::Relaxed); + match self.emit { + Emit::Nothing => {} + Emit::Float(value) => table.push(ValueType::Float, NodeValue::Float(value), None), + Emit::Time => table.push(ValueType::Float, NodeValue::Float(time.to_f64()), None), + Emit::Texture => { + table.push(ValueType::Texture, NodeValue::Texture(CHandle::null()), None) + } + } + } + + fn duplicate(&self, _core: &NodeCore) -> Option> { + None + } + } + + /// Add a [`TestNode`] declaring `inputs`; returns its id and call counter. + fn add_node( + graph: &mut Graph, + inputs: &[(&str, ValueType)], + emit: Emit, + ) -> (NodeId, Arc) { + let calls = Arc::new(AtomicUsize::new(0)); + let mut core = NodeCore::new(); + for (id, value_type) in inputs { + core.add_input(Input::new(id, *value_type, NodeValue::None)); + } + let id = graph.add_node( + core, + Box::new(TestNode { + emit, + calls: Arc::clone(&calls), + }), + ); + (id, calls) + } + + /// The endpoint pair with the default `input -> output` edge removed, + /// so a test can wire the convergence itself. + fn detached_endpoints(graph: &mut Graph) -> (NodeId, NodeId) { + let (input, output) = graph.ensure_endpoints(); + graph.disconnect(input, output, GRAPH_OUTPUT_INPUT, -1); + (input, output) + } + + /// A node that samples `val_in` one second late: observes that the + /// sweep pulls a time-shifted upstream through the DFS memo. + struct Delay { + rows: Arc>>, + } + + impl NodeBehavior for Delay { + fn name(&self) -> &str { + "Delay" + } + + fn type_id(&self) -> &str { + "org.olivevideoeditor.Olive.test-bfs-delay" + } + + fn input_time_adjustment( + &self, + _core: &NodeCore, + input: &str, + _element: i32, + time: TimeRange, + _traverse: bool, + ) -> TimeRange { + if input == "val_in" { + TimeRange::new( + time.in_() + Rational::new(1, 1), + time.out() + Rational::new(1, 1), + ) + } else { + time + } + } + + fn value( + &self, + _core: &NodeCore, + inputs: &NodeValueRow, + _time: Rational, + table: &mut NodeValueTable, + ) { + self.rows.lock().expect("delay rows").push(inputs.clone()); + if let Some(value @ NodeValue::Float(_)) = inputs.get("val_in") { + table.push(ValueType::Float, value.clone(), None); + } + } + + fn duplicate(&self, _core: &NodeCore) -> Option> { + None + } + } + + /// Records resolve order/rows and can replace a node's resolved table. + struct Probe { + order: Vec, + rows: HashMap, + replace: Option<(NodeId, Vec<(ValueType, NodeValue)>)>, + } + + impl Probe { + fn new() -> Probe { + Probe { + order: Vec::new(), + rows: HashMap::new(), + replace: None, + } + } + } + + impl RenderHooks for Probe { + fn resolve(&mut self, node: NodeId, row: &NodeValueRow, table: &mut NodeValueTable) { + self.order.push(node); + self.rows.insert(node, row.clone()); + if let Some((target, rows)) = &self.replace { + if *target == node { + table.clear(); + for (ty, value) in rows { + table.push(*ty, value.clone(), None); + } + } + } + } + } + + #[test] + fn bfs_starts_at_graph_input_and_skips_unreachable_nodes() { + let mut graph = Graph::new(); + let (input, output) = detached_endpoints(&mut graph); + let (dead, dead_calls) = + add_node(&mut graph, &[("x_in", ValueType::Texture)], Emit::Nothing); + let (_island, island_calls) = add_node(&mut graph, &[], Emit::Float(1.0)); + graph + .connect(input, dead, "x_in", -1) + .expect("input -> dead"); + graph + .connect(input, output, GRAPH_OUTPUT_INPUT, -1) + .expect("input -> output"); + + let mut probe = Probe::new(); + let value = Traverser::new() + .eval_graph_bfs(&graph, Rational::new(0, 1), &mut probe) + .expect("the input -> output edge alone is a valid graph"); + assert_eq!(probe.order, vec![input, output]); + assert_eq!(dead_calls.load(Ordering::Relaxed), 0, "dead branch skipped"); + assert_eq!( + island_calls.load(Ordering::Relaxed), + 0, + "island never queued" + ); + assert_eq!(value, NodeValue::None); + } + + #[test] + fn bfs_waits_for_every_input_of_a_converging_node() { + let mut graph = Graph::new(); + let (input, output) = detached_endpoints(&mut graph); + let (a, _) = add_node(&mut graph, &[("tex_in", ValueType::Texture)], Emit::Float(1.0)); + let (b, _) = add_node(&mut graph, &[("tex_in", ValueType::Texture)], Emit::Float(2.0)); + let (merge, _) = add_node( + &mut graph, + &[("a_in", ValueType::Float), ("b_in", ValueType::Float)], + Emit::Float(3.0), + ); + graph.connect(input, a, "tex_in", -1).expect("input -> a"); + graph.connect(input, b, "tex_in", -1).expect("input -> b"); + graph.connect(a, merge, "a_in", -1).expect("a -> merge"); + graph.connect(b, merge, "b_in", -1).expect("b -> merge"); + graph + .connect(merge, output, GRAPH_OUTPUT_INPUT, -1) + .expect("merge -> output"); + + let mut probe = Probe::new(); + Traverser::new() + .eval_graph_bfs(&graph, Rational::new(0, 1), &mut probe) + .expect("the converging graph is orderable"); + assert_eq!(probe.order, vec![input, a, b, merge, output]); + let row = probe.rows.get(&merge).expect("merge resolved"); + assert_eq!(row.get("a_in"), Some(&NodeValue::Float(1.0))); + assert_eq!(row.get("b_in"), Some(&NodeValue::Float(2.0))); + } + + #[test] + fn bfs_evaluates_a_shared_source_once() { + let mut graph = Graph::new(); + let (input, output) = detached_endpoints(&mut graph); + let (source, source_calls) = add_node( + &mut graph, + &[("tex_in", ValueType::Texture)], + Emit::Float(7.0), + ); + let (m, _) = add_node(&mut graph, &[("a_in", ValueType::Float)], Emit::Float(1.0)); + let (n, _) = add_node(&mut graph, &[("a_in", ValueType::Float)], Emit::Float(2.0)); + let (f, _) = add_node( + &mut graph, + &[("c_in", ValueType::Float), ("d_in", ValueType::Float)], + Emit::Float(3.0), + ); + graph + .connect(input, source, "tex_in", -1) + .expect("input -> source"); + graph.connect(source, m, "a_in", -1).expect("source -> m"); + graph.connect(source, n, "a_in", -1).expect("source -> n"); + graph.connect(m, f, "c_in", -1).expect("m -> f"); + graph.connect(n, f, "d_in", -1).expect("n -> f"); + graph + .connect(f, output, GRAPH_OUTPUT_INPUT, -1) + .expect("f -> output"); + + let mut probe = Probe::new(); + Traverser::new() + .eval_graph_bfs(&graph, Rational::new(0, 1), &mut probe) + .expect("the fan-out graph is orderable"); + assert_eq!(probe.order, vec![input, source, m, n, f, output]); + assert_eq!( + source_calls.load(Ordering::Relaxed), + 1, + "the shared source runs once" + ); + assert_eq!( + probe.rows.get(&m).and_then(|r| r.get("a_in")), + Some(&NodeValue::Float(7.0)) + ); + assert_eq!( + probe.rows.get(&n).and_then(|r| r.get("a_in")), + Some(&NodeValue::Float(7.0)) + ); + } + + #[test] + fn bfs_dequeue_order_is_deterministic() { + let mut graph = Graph::new(); + let (input, output) = detached_endpoints(&mut graph); + let (a, _) = add_node(&mut graph, &[("tex_in", ValueType::Texture)], Emit::Float(1.0)); + let (b, _) = add_node(&mut graph, &[("tex_in", ValueType::Texture)], Emit::Float(2.0)); + let (c, _) = add_node(&mut graph, &[("tex_in", ValueType::Texture)], Emit::Float(3.0)); + let (merge, _) = add_node( + &mut graph, + &[ + ("a_in", ValueType::Float), + ("b_in", ValueType::Float), + ("c_in", ValueType::Float), + ], + Emit::Float(4.0), + ); + for (from, to, input) in [ + (input, a, "tex_in"), + (input, b, "tex_in"), + (input, c, "tex_in"), + (a, merge, "a_in"), + (b, merge, "b_in"), + (c, merge, "c_in"), + (merge, output, GRAPH_OUTPUT_INPUT), + ] { + graph + .connect(from, to, input, -1) + .unwrap_or_else(|e| panic!("{from:?} -> {to:?}.{input}: {e:?}")); + } + + let mut first = Probe::new(); + Traverser::new() + .eval_graph_bfs(&graph, Rational::new(0, 1), &mut first) + .expect("the fan-in graph is orderable"); + let mut second = Probe::new(); + Traverser::new() + .eval_graph_bfs(&graph, Rational::new(0, 1), &mut second) + .expect("the fan-in graph is orderable"); + assert_eq!(first.order, vec![input, a, b, c, merge, output]); + assert_eq!(first.order, second.order, "same graph -> same order"); + } + + #[test] + fn bfs_pulls_time_shifted_upstreams_through_the_dfs_memo() { + let mut graph = Graph::new(); + let (input, output) = detached_endpoints(&mut graph); + let (source, source_calls) = add_node(&mut graph, &[], Emit::Time); + let rows = Arc::new(Mutex::new(Vec::new())); + let mut core = NodeCore::new(); + core.add_input(Input::new("tex_in", ValueType::Texture, NodeValue::None)); + core.add_input(Input::new("val_in", ValueType::Float, NodeValue::None)); + let delay = graph.add_node( + core, + Box::new(Delay { + rows: Arc::clone(&rows), + }), + ); + graph + .connect(input, delay, "tex_in", -1) + .expect("input -> delay.tex_in"); + graph + .connect(source, delay, "val_in", -1) + .expect("source -> delay.val_in"); + graph + .connect(delay, output, GRAPH_OUTPUT_INPUT, -1) + .expect("delay -> output"); + + let mut probe = Probe::new(); + let value = Traverser::new() + .eval_graph_bfs(&graph, Rational::new(0, 1), &mut probe) + .expect("an out-of-live-set feeder does not stall the sweep"); + assert_eq!( + probe.order, + vec![input, source, delay, output], + "the far end is pulled during delay's turn, not queued" + ); + assert_eq!(source_calls.load(Ordering::Relaxed), 1); + let rows = rows.lock().expect("delay rows"); + assert_eq!(rows.len(), 1); + // One second late: the sweep pulled the source at time 1, not 0. + assert_eq!(rows[0].get("val_in"), Some(&NodeValue::Float(1.0))); + assert_eq!(value, NodeValue::None); + } + + #[test] + fn bfs_hands_resolved_tables_to_downstream_nodes() { + let mut graph = Graph::new(); + let (input, output) = detached_endpoints(&mut graph); + let (a, _) = add_node(&mut graph, &[("tex_in", ValueType::Texture)], Emit::Float(1.0)); + let (b, _) = add_node(&mut graph, &[("tex_in", ValueType::Texture)], Emit::Float(2.0)); + let (merge, _) = add_node( + &mut graph, + &[("a_in", ValueType::Float), ("b_in", ValueType::Float)], + Emit::Float(3.0), + ); + graph.connect(input, a, "tex_in", -1).expect("input -> a"); + graph.connect(input, b, "tex_in", -1).expect("input -> b"); + graph.connect(a, merge, "a_in", -1).expect("a -> merge"); + graph.connect(b, merge, "b_in", -1).expect("b -> merge"); + graph + .connect(merge, output, GRAPH_OUTPUT_INPUT, -1) + .expect("merge -> output"); + + let mut probe = Probe::new(); + probe.replace = Some(( + a, + vec![(ValueType::Float, NodeValue::Float(999.0))], + )); + Traverser::new() + .eval_graph_bfs(&graph, Rational::new(0, 1), &mut probe) + .expect("the converging graph is orderable"); + let row = probe.rows.get(&merge).expect("merge resolved"); + assert_eq!( + row.get("a_in"), + Some(&NodeValue::Float(999.0)), + "the downstream row carries what resolve() left in a's table" + ); + assert_eq!(row.get("b_in"), Some(&NodeValue::Float(2.0))); + } + + #[test] + fn bfs_reports_a_cycle_and_names_its_nodes() { + let mut graph = Graph::new(); + let (input, output) = detached_endpoints(&mut graph); + let (a, a_calls) = add_node(&mut graph, &[("in", ValueType::Float)], Emit::Float(1.0)); + let (b, b_calls) = add_node(&mut graph, &[("in", ValueType::Float)], Emit::Float(2.0)); + graph.connect(input, a, "in", -1).expect("input -> a"); + graph.connect(a, b, "in", -1).expect("a -> b"); + graph + .connect(b, output, GRAPH_OUTPUT_INPUT, -1) + .expect("b -> output"); + graph.force_connect(b, a, "in", -1); + + let mut probe = Probe::new(); + let error = Traverser::new() + .eval_graph_bfs(&graph, Rational::new(0, 1), &mut probe) + .expect_err("a cycle must be reported, not ordered"); + let message = match error { + Error::Failed(message) => message, + other => panic!("Failed expected, got {other:?}"), + }; + assert!(message.contains("cycle"), "message names the failure: {message}"); + assert!( + message.contains(&format!("{a:?}")), + "message names {a:?}: {message}" + ); + assert!( + message.contains(&format!("{b:?}")), + "message names {b:?}: {message}" + ); + assert_eq!(probe.order, vec![input], "nothing past the cycle resolves"); + assert_eq!(a_calls.load(Ordering::Relaxed), 0); + assert_eq!(b_calls.load(Ordering::Relaxed), 0); + } + + #[test] + fn bfs_returns_the_output_texture() { + let mut graph = Graph::new(); + let (input, output) = detached_endpoints(&mut graph); + let (source, source_calls) = add_node(&mut graph, &[], Emit::Texture); + graph + .connect(source, input, GRAPH_INPUT_FEED_INPUT, -1) + .expect("source -> feed_in"); + graph + .connect(input, output, GRAPH_OUTPUT_INPUT, -1) + .expect("input -> output"); + + let mut probe = Probe::new(); + let value = Traverser::new() + .eval_graph_bfs(&graph, Rational::new(0, 1), &mut probe) + .expect("a fed input -> output graph is orderable"); + match value { + NodeValue::Texture(handle) => assert!(handle.ctx.is_null()), + other => panic!("texture expected, got {other:?}"), + } + assert_eq!(probe.order, vec![source, input, output]); + assert_eq!(source_calls.load(Ordering::Relaxed), 1); + } + + #[test] + fn bfs_requires_both_endpoints() { + let mut probe = Probe::new(); + let error = Traverser::new() + .eval_graph_bfs(&Graph::new(), Rational::new(0, 1), &mut probe) + .expect_err("an endpointless graph cannot be swept"); + assert_eq!(error, Error::NotFound); + } +} diff --git a/crates/oak-node/tests/factory_smoke_test.rs b/crates/oak-node/tests/factory_smoke_test.rs index ccd42cfa3..acf00953c 100644 --- a/crates/oak-node/tests/factory_smoke_test.rs +++ b/crates/oak-node/tests/factory_smoke_test.rs @@ -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 diff --git a/crates/oak-node/tests/serializer_test.rs b/crates/oak-node/tests/serializer_test.rs index 9ec89af4c..13aa15e27 100644 --- a/crates/oak-node/tests/serializer_test.rs +++ b/crates/oak-node/tests/serializer_test.rs @@ -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)); +} diff --git a/crates/oak-node/tests/structure_test.rs b/crates/oak-node/tests/structure_test.rs index cec5ba538..6c3d990b4 100644 --- a/crates/oak-node/tests/structure_test.rs +++ b/crates/oak-node/tests/structure_test.rs @@ -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); diff --git a/crates/oak-render/src/eval.rs b/crates/oak-render/src/eval.rs index b830c12ba..f709f8c87 100644 --- a/crates/oak-render/src/eval.rs +++ b/crates/oak-render/src/eval.rs @@ -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::(&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); + } + } diff --git a/crates/oak-storage/tests/common/mod.rs b/crates/oak-storage/tests/common/mod.rs index 55ca1d407..a203e19e5 100644 --- a/crates/oak-storage/tests/common/mod.rs +++ b/crates/oak-storage/tests/common/mod.rs @@ -367,8 +367,23 @@ pub(crate) fn assert_full_state(orig: &Project, loaded: &Project) { .collect(); assert_eq!(l_types, o_types, "node types"); - // Math A: label, color, value, keyframes. - let (a_o, a_l) = (o_ids[1], l_ids[1]); + // Math A: label, color, value, keyframes. The graph endpoints (M0b) + // sit between the root folder and the content nodes, so pick the two + // math nodes by type instead of by position (slot order still lines + // them up pairwise). + let o_math: Vec = o_ids + .iter() + .copied() + .filter(|id| orig.graph.get(*id).unwrap().behavior.type_id() == MATH) + .collect(); + let l_math: Vec = l_ids + .iter() + .copied() + .filter(|id| loaded.graph.get(*id).unwrap().behavior.type_id() == MATH) + .collect(); + assert_eq!(l_math.len(), 2, "fixture math nodes"); + let (a_o, a_l) = (o_math[0], l_math[0]); + let b_l = l_math[1]; assert_eq!( loaded.graph.get(a_l).unwrap().core.label, orig.graph.get(a_o).unwrap().core.label, @@ -422,11 +437,11 @@ pub(crate) fn assert_full_state(orig: &Project, loaded: &Project) { // Connection a -> b.param_b_in and the link. assert_eq!( - loaded.graph.connected_output(l_ids[2], "param_b_in", -1), + loaded.graph.connected_output(b_l, "param_b_in", -1), Some(a_l), "connection" ); - assert!(loaded.graph.are_linked(a_l, l_ids[2]), "link"); + assert!(loaded.graph.are_linked(a_l, b_l), "link"); // Timeline: sequence, track lists, track, clips, footage. let mut seq: Option<(NodeId, Vec)> = None; diff --git a/crates/oak-storage/tests/database_test.rs b/crates/oak-storage/tests/database_test.rs index 82bb6b2e3..1a6e8a31c 100644 --- a/crates/oak-storage/tests/database_test.rs +++ b/crates/oak-storage/tests/database_test.rs @@ -282,8 +282,9 @@ fn no_op_save_is_a_touch_only() { (proj.command_seq, n) }); assert_eq!(head, 1, "no-op save keeps the head seq"); - // The import wrote 12 nodes + 1 settings row; the no-op added none. - assert_eq!(count, 13, "only the import rows remain"); + // The import wrote 12 content nodes + the 2 graph endpoints (M0b) + // + 1 settings row; the no-op added none. + assert_eq!(count, 15, "only the import rows remain"); } // --------------------------------------------------------------------------- @@ -461,7 +462,15 @@ fn undo_to_any_point() { let at1 = load_at(&session, &uri, &uuid, 1); { let l = at1.lock().unwrap(); - assert_eq!(l.graph.node_count(), 12, "fixture node count"); + // The graph endpoints (M0b) are not part of the fixture's 12 + // content nodes. + let content = l + .graph + .node_ids() + .into_iter() + .filter(|id| !l.graph.is_endpoint(*id)) + .count(); + assert_eq!(content, 12, "fixture node count"); let id = l .graph .node_ids() @@ -474,12 +483,22 @@ fn undo_to_any_point() { ); } - // Undo to seq 0: an empty project. + // Undo to seq 0: an empty project. The graph endpoints (M0b) are + // recreated at load time, so "empty" means no content nodes. let session = DatabaseBackend::new(); let at0 = load_at(&session, &uri, &uuid, 0); { let l = at0.lock().unwrap(); - assert_eq!(l.graph.node_count(), 0, "empty project at seq 0"); + assert_eq!( + l.graph + .node_ids() + .into_iter() + .filter(|id| !l.graph.is_endpoint(*id)) + .count(), + 0, + "empty project at seq 0" + ); + assert!(l.graph.endpoints().is_some(), "endpoints exist even in the empty state"); } // Out of range -> E_INVALID. @@ -636,7 +655,14 @@ fn journal_retention_truncation() { l.graph.get(math_ids[0]).unwrap().core.standard_value("param_a_in", -1), NodeValue::Float(4.5) ); - assert_eq!(l.graph.node_count(), 12); + assert_eq!( + l.graph + .node_ids() + .into_iter() + .filter(|id| !l.graph.is_endpoint(*id)) + .count(), + 12 + ); } } @@ -882,7 +908,14 @@ fn export_and_import_round_trip() { assert_eq!(imported_uuid, imported); { let l = imported_proj.lock().unwrap(); - assert_eq!(l.graph.node_count(), 12); + assert_eq!( + l.graph + .node_ids() + .into_iter() + .filter(|id| !l.graph.is_endpoint(*id)) + .count(), + 12 + ); assert_eq!(l.settings.get("projectname").cloned(), Some("full-fixture".to_string())); } diff --git a/crates/oak-storage/tests/storage_test.rs b/crates/oak-storage/tests/storage_test.rs index 2f125adf8..a1961ea29 100644 --- a/crates/oak-storage/tests/storage_test.rs +++ b/crates/oak-storage/tests/storage_test.rs @@ -198,10 +198,24 @@ fn assert_roundtrip_fields(orig: &Project, loaded: &Project) { .collect(); assert_eq!(l_types, o_types, "node types"); - let a_o = o_ids[1]; - let a_l = l_ids[1]; - let b_o = o_ids[2]; - let b_l = l_ids[2]; + // The graph endpoints (M0b) sit between the root folder and the + // content nodes, so pick the two math nodes by type instead of by + // position (slot order still lines them up pairwise). + let o_math: Vec = o_ids + .iter() + .copied() + .filter(|id| orig.graph.get(*id).unwrap().behavior.type_id() == MATH) + .collect(); + let l_math: Vec = l_ids + .iter() + .copied() + .filter(|id| loaded.graph.get(*id).unwrap().behavior.type_id() == MATH) + .collect(); + assert_eq!(l_math.len(), 2, "fixture math nodes"); + let a_o = o_math[0]; + let a_l = l_math[0]; + let b_o = o_math[1]; + let b_l = l_math[1]; // Label + color. assert_eq!( diff --git a/docs/zh/plans/render-pipeline-threads.md b/docs/zh/plans/render-pipeline-threads.md index 5beb784fb..7561eba62 100644 --- a/docs/zh/plans/render-pipeline-threads.md +++ b/docs/zh/plans/render-pipeline-threads.md @@ -349,6 +349,9 @@ fallback。** 解码上传与上屏共用一层 `gpuinteop` 抽象,按后端 M1(与 M2 可并行);M4 依赖 M2;M5 依赖 M2(YUV→RGB pass 与互操作抽象), 可拆成并行子项。 +**全程验收(用户要求)**:M5 完成、整个任务收官后,跑一轮**分支覆盖率** +(branch coverage)测量并留档,作为管线改造整体的质量闸门。 + ## 5. 不变量与边界 - **不动**:ticket API(oak-app/oak-cli 无感)、撤销/重做、缓存磁盘格式、