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:
@@ -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"
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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é"
|
||||
|
||||
@@ -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": "プロキシ:オン"
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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": "Прокси: вкл"
|
||||
|
||||
@@ -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": "代理:开"
|
||||
|
||||
@@ -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<Box<dyn NodeBehavior>> {
|
||||
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<NodeId> {
|
||||
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<Box<dyn NodeBehavior>> {
|
||||
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)]
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 <http://www.gnu.org/licenses/>.
|
||||
|
||||
//! 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<Box<dyn NodeBehavior>> {
|
||||
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<Box<dyn NodeBehavior>> {
|
||||
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<dyn NodeBehavior>) {
|
||||
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<dyn NodeBehavior>) {
|
||||
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<NodeMeta>) {
|
||||
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());
|
||||
}
|
||||
}
|
||||
@@ -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.
|
||||
|
||||
@@ -163,18 +163,24 @@ static MEASURE: std::sync::Mutex<Option<TextMeasureBackend>> = std::sync::Mutex:
|
||||
/// Installed render hook (C++ global `g_text_render_backend`).
|
||||
static RENDER: std::sync::Mutex<Option<TextRenderBackend>> = 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,
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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(())
|
||||
}
|
||||
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
|
||||
@@ -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<Frame> = 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<NodeValue> {
|
||||
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<NodeId, usize> = 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<NodeId> = 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<Frame> = 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<NodeId> {
|
||||
let mut seen: HashSet<NodeId> = HashSet::new();
|
||||
let mut stack: Vec<NodeId> = 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<NodeId> {
|
||||
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<NodeId, usize>) -> Vec<NodeId> {
|
||||
let residual: BTreeSet<NodeId> = indegree
|
||||
.iter()
|
||||
.filter(|(_, n)| **n > 0)
|
||||
.map(|(node, _)| *node)
|
||||
.collect();
|
||||
let mut path: Vec<NodeId> = Vec::new();
|
||||
let mut seen: HashMap<NodeId, usize> = 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<AtomicUsize>,
|
||||
}
|
||||
|
||||
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<Box<dyn NodeBehavior>> {
|
||||
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<AtomicUsize>) {
|
||||
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<Mutex<Vec<NodeValueRow>>>,
|
||||
}
|
||||
|
||||
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<Box<dyn NodeBehavior>> {
|
||||
None
|
||||
}
|
||||
}
|
||||
|
||||
/// Records resolve order/rows and can replace a node's resolved table.
|
||||
struct Probe {
|
||||
order: Vec<NodeId>,
|
||||
rows: HashMap<NodeId, NodeValueRow>,
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -3989,5 +3989,122 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
// ---- Endpoint-anchored BFS sweep (M0b) ------------------------------
|
||||
|
||||
/// The M0b integration path over real media and a real effect: a test
|
||||
/// clip probed into a footage node feeds `GraphInput.feed_in`, a
|
||||
/// Position node sits between the endpoints, and `eval_graph_bfs`
|
||||
/// decodes the frame, runs the effect pass and returns the output
|
||||
/// endpoint's texture. The Position offset is `(16, 0)`, so the frame
|
||||
/// must come out shifted right by 16 pixels — the pixels alone prove
|
||||
/// both the decode and the shader pass ran inside the sweep.
|
||||
#[test]
|
||||
fn bfs_endpoint_sweep_renders_footage_through_position() {
|
||||
use oak_node::nodes::graphendpoints::{GRAPH_INPUT_FEED_INPUT, GRAPH_OUTPUT_INPUT};
|
||||
|
||||
if oak_core::backend::GpuContext::shared().is_none() {
|
||||
eprintln!("no adapter; skipping");
|
||||
return;
|
||||
}
|
||||
|
||||
let path = std::env::temp_dir().join(format!(
|
||||
"oakrender_bfs_endpoints_{}.mp4",
|
||||
std::process::id()
|
||||
));
|
||||
oak_codec::testmedia::write_test_clip_solid(
|
||||
&path,
|
||||
64,
|
||||
64,
|
||||
10,
|
||||
10,
|
||||
[0.9, 0.2, 0.1, 1.0],
|
||||
)
|
||||
.expect("test clip generation");
|
||||
|
||||
let mut graph = oak_node::graph::Graph::new();
|
||||
let (input, output) = graph.ensure_endpoints();
|
||||
// The default input -> output edge is replaced by the effect chain.
|
||||
graph.disconnect(input, output, GRAPH_OUTPUT_INPUT, -1);
|
||||
|
||||
let (core, _) = oak_node::footage::FootageBehavior::create();
|
||||
let mut footage = oak_node::footage::FootageBehavior::new(&path.to_string_lossy());
|
||||
footage.probe().expect("probe the test clip");
|
||||
let footage = graph.add_node(core, Box::new(footage));
|
||||
|
||||
let (core, behavior) = oak_node::factory::Factory::global()
|
||||
.create_any("org.olivevideoeditor.Olive.position")
|
||||
.expect("position node registered");
|
||||
let position = graph.add_node(core, behavior);
|
||||
graph
|
||||
.get_mut(position)
|
||||
.expect("the position node is live")
|
||||
.core
|
||||
.set_standard_value("offset_in", -1, NodeValue::Vec2([16.0, 0.0]));
|
||||
|
||||
graph
|
||||
.connect(footage, input, GRAPH_INPUT_FEED_INPUT, -1)
|
||||
.expect("footage -> GraphInput.feed_in");
|
||||
graph
|
||||
.connect(input, position, "tex_in", -1)
|
||||
.expect("GraphInput.tex_out -> position.tex_in");
|
||||
graph
|
||||
.connect(position, output, GRAPH_OUTPUT_INPUT, -1)
|
||||
.expect("position.tex_out -> GraphOutput.tex_in");
|
||||
|
||||
let mut hooks = RenderEvalHooks::new();
|
||||
hooks.frame_size = Some((64, 64));
|
||||
let value = oak_node::traverser::Traverser::new()
|
||||
.eval_graph_bfs(&graph, Rational::new(0, 1), &mut hooks)
|
||||
.expect("the endpoint sweep runs");
|
||||
|
||||
let NodeValue::Texture(handle) = value else {
|
||||
panic!("the sweep must return the output endpoint's texture");
|
||||
};
|
||||
assert!(!handle.ctx.is_null(), "the returned box is still a job");
|
||||
let texture = (unsafe { oak_node::handle::get_checked::<Texture>(&handle) })
|
||||
.cloned()
|
||||
.expect("the returned box holds a Texture");
|
||||
assert_eq!(texture.size(), (64, 64));
|
||||
let frame = texture.to_frame().expect("readback");
|
||||
|
||||
let reference = render_footage_frame(
|
||||
&path.to_string_lossy(),
|
||||
0,
|
||||
Rational::new(0, 1),
|
||||
(64, 64),
|
||||
PixelFormat::F32,
|
||||
)
|
||||
.expect("reference decode");
|
||||
let reference = reference.to_frame().expect("reference readback");
|
||||
let reference_px = pixel_at(&reference, 32, 32);
|
||||
assert!(
|
||||
reference_px[0] > 0.3 && reference_px[0] > 4.0 * reference_px[1],
|
||||
"the reference clip is red-dominant: {reference_px:?}"
|
||||
);
|
||||
|
||||
// The vacated left columns are transparent, not a clamped edge
|
||||
// column (the Position node's whole-pixel translation).
|
||||
for (x, y) in [(0, 0), (8, 32), (15, 63)] {
|
||||
assert!(
|
||||
pixel_at(&frame, x, y)[3] < 1e-4,
|
||||
"({x},{y}) must be transparent after the shift: {:?}",
|
||||
pixel_at(&frame, x, y)
|
||||
);
|
||||
}
|
||||
// The footage content arrives at (x + 16, y).
|
||||
for (x, y) in [(16, 0), (32, 32), (63, 63)] {
|
||||
let got = pixel_at(&frame, x, y);
|
||||
let want = pixel_at(&reference, x - 16, y);
|
||||
for (c, (g, w)) in got.iter().zip(want).enumerate() {
|
||||
assert!(
|
||||
(g - w).abs() < 1e-3,
|
||||
"shifted pixel ({x},{y}) ch{c}: got {g}, want {w}"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
let _ = std::fs::remove_file(&path);
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
|
||||
@@ -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<NodeId> = o_ids
|
||||
.iter()
|
||||
.copied()
|
||||
.filter(|id| orig.graph.get(*id).unwrap().behavior.type_id() == MATH)
|
||||
.collect();
|
||||
let l_math: Vec<NodeId> = 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<NodeId>)> = None;
|
||||
|
||||
@@ -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()));
|
||||
}
|
||||
|
||||
|
||||
@@ -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<NodeId> = o_ids
|
||||
.iter()
|
||||
.copied()
|
||||
.filter(|id| orig.graph.get(*id).unwrap().behavior.type_id() == MATH)
|
||||
.collect();
|
||||
let l_math: Vec<NodeId> = 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!(
|
||||
|
||||
@@ -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 无感)、撤销/重做、缓存磁盘格式、
|
||||
|
||||
Reference in New Issue
Block a user