Files
oak-editor/crates/oak-node/src/traverser.rs
T
Mike-Solar 29204d1f63 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.
2026-09-11 15:13:53 +08:00

1029 lines
33 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// 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 evaluation engine — the C++ `NodeTraverser` restructured.
//!
//! Key change from C++: no inheritance. C++ `RenderProcessor :
//! NodeTraverser` overrode virtuals to plug rendering in; here the
//! traverser is a free engine and oakrender supplies [`RenderHooks`].
//!
//! Evaluation is **time-aware and memoized per (node, time)**: a node's
//! inputs may pull upstream values at adjusted times (the consuming
//! node's `input_time_adjustment` — clips map sequence time to media
//! time, tracks clamp to the covering block, C++
//! `traverser.cpp` `ProcessInput`), so one evaluation pass can evaluate
//! the same node at several times (keyed like the C++ `value_cache_`,
//! which is per (node, range)). The walk is an explicit-stack DFS —
//! 10k-deep chains must not blow the call stack (the earlier
//! topological-order pass was recursion-free for the same reason).
//!
//! Input rows carry the C++ `GenerateRowValue` semantics: connected
//! inputs take the upstream output (evaluated at the adjusted time);
//! unconnected inputs take `NodeCore::value_at_time` — keyframe
//! interpolation when the track is non-empty, else the standard value
//! (C++ `ProcessInputElement` → `GetValueAtTime`).
//! `// CPP-PARITY: src/node/src/traverser.cpp`.
use std::collections::{BTreeSet, HashMap, HashSet};
use oak_core::{Rational, TimeRange};
use crate::graph::Graph;
use crate::id::NodeId;
use crate::value::{NodeValue, NodeValueRow, NodeValueTable, ValueType};
/// Backend hooks supplied by the consumer (oakrender). Default no-ops
/// give the C++ "offline evaluation" behavior.
pub trait RenderHooks {
/// Whether cached textures may be used (C++ `use_cache()`).
fn use_cache(&self) -> bool {
false
}
/// Convert a finished value row into a backend job/texture
/// (C++ `resolve_jobs` / `process_*_job` family).
fn resolve(&mut self, node: NodeId, row: &NodeValueRow, table: &mut NodeValueTable) {
let _ = (node, row, table);
}
/// Cancel-check polled between nodes (C++ `IsCancelled`).
fn is_cancelled(&self) -> bool {
false
}
}
/// Evaluation request.
pub struct EvalRequest {
/// Root node to evaluate.
pub root: NodeId,
/// Time.
pub time: Rational,
/// Optional range (for audio pulls).
pub range: Option<TimeRange>,
}
impl EvalRequest {
/// New request.
pub fn new(root: NodeId, time: Rational) -> EvalRequest {
EvalRequest {
root,
time,
range: None,
}
}
}
/// The traversal engine.
pub struct Traverser {
/// Nodes touched by the last [`Traverser::invalidate_downstream`]
/// walk (observable for tests; the C++ fan-out has no return value).
last_invalidation: Vec<NodeId>,
}
/// DFS stack frame: `Enter` queues the upstream nodes, `Exit` builds the
/// row and evaluates.
enum Frame {
Enter(NodeId, Rational),
Exit(NodeId, Rational),
}
impl Traverser {
/// New empty engine (reusable across evaluations).
pub fn new() -> Self {
Traverser {
last_invalidation: Vec::new(),
}
}
/// Nodes marked by the last invalidation walk.
pub fn last_invalidation(&self) -> &[NodeId] {
&self.last_invalidation
}
/// Evaluate `request` against `graph`, calling `hooks` at the
/// backend seams. Returns the root's output table.
///
/// Errors: `State` on cancellation, `NotFound` on an invalid root.
/// Only nodes upstream of the root are evaluated (lazy — the C++
/// recursion shares this property).
pub fn evaluate(
&mut self,
graph: &Graph,
request: &EvalRequest,
hooks: &mut dyn RenderHooks,
) -> crate::error::Result<NodeValueTable> {
use crate::error::Error;
if !graph.is_valid(request.root) {
return Err(Error::NotFound);
}
// 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();
walk_dfs(graph, &mut cache, request.root, request.time, hooks)?;
Ok(cache
.remove(&(request.root, request.time))
.unwrap_or_default())
}
/// 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);
}
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;
}
if let Some(n) = indegree.get_mut(&to) {
*n -= 1;
if *n == 0 {
ready.insert(to);
}
}
}
}
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
/// change (C++ `invalidate_cache` fan-out, signal-free). Records the
/// walked set in [`Traverser::last_invalidation`].
pub fn invalidate_downstream(&mut self, graph: &Graph, from: NodeId, range: TimeRange) {
let _ = range;
self.last_invalidation.clear();
let mut seen: HashSet<NodeId> = HashSet::new();
let mut queue: Vec<NodeId> = vec![from];
while let Some(n) = queue.pop() {
if !seen.insert(n) {
continue;
}
self.last_invalidation.push(n);
queue.extend(graph.downstream(n));
}
}
}
impl Default for Traverser {
fn default() -> Self {
Self::new()
}
}
/// 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
/// trait speaks ranges; a video frame evaluates at a point, so the
/// adjusted range's `in` is the upstream time.
fn adjusted_time(
entry: &crate::graph::NodeEntry,
input: &str,
element: i32,
time: Rational,
) -> Rational {
entry
.behavior
.input_time_adjustment(&entry.core, input, element, TimeRange::new(time, time), true)
.in_()
}
/// Build the input row of `node` at `time` from the memoized upstream
/// tables plus the standard/keyframed values of unconnected inputs
/// (C++ `GenerateRowValue` + `ProcessInputElement`).
fn build_row(
graph: &Graph,
cache: &HashMap<(NodeId, Rational), NodeValueTable>,
entry: &crate::graph::NodeEntry,
node: NodeId,
time: Rational,
) -> NodeValueRow {
let mut row: NodeValueRow = std::collections::BTreeMap::new();
let connections = graph.input_connections(node);
for input in &entry.core.inputs {
let id = input.id.as_str();
let mut conns: Vec<(NodeId, i32)> = connections
.iter()
.filter(|(_, i, _)| i == id)
.map(|(from, _, element)| (*from, *element))
.collect();
if conns.is_empty() {
// Unconnected: keyframe interpolation when the track is
// non-empty, else the standard value (C++ GetValueAtTime).
row.insert(id.to_string(), entry.core.value_at_time(id, -1, time));
continue;
}
// Array inputs (element >= 0): the consuming node may restrict
// which elements are live at this time (C++
// `GetActiveElementsAtTime` — a track pulls only the blocks
// covering the frame). An empty answer means "no restriction".
// Element-tagged keys: an array input's per-element values coexist
// in the row under `{input}[{element}]` (C++ `GetValueAtTime`
// indexes the array; the multi-cam node reads exactly the element
// of its current source — a plain `id` key would collapse the
// array to its last element).
if conns.iter().any(|(_, e)| *e >= 0) {
let active = entry.behavior.active_elements_at_time(id, time);
if !active.is_empty() {
conns.retain(|(_, e)| active.contains(e));
}
conns.sort_by_key(|(_, e)| *e);
}
for (from, element) in conns {
let from = entry
.behavior
.connected_render_output(&entry.core, id, element)
.unwrap_or(from);
let upstream_time = adjusted_time(entry, id, element, time);
let value = cache
.get(&(from, upstream_time))
.map(|t| pick_value(t, entry.core.input_data_type(id)))
.unwrap_or(NodeValue::None);
let key = if element >= 0 {
format!("{id}[{element}]")
} else {
id.to_string()
};
row.insert(key, value);
}
}
row
}
/// Pick the row value for an input of `data_type` from an upstream
/// output table. Texture inputs take the upstream texture directly (the
/// scalar chain would otherwise hand a plugin node's tagged param
/// passthrough to a downstream clip input); everything else takes the
/// last value of the first matching scalar type (C++ value-hint
/// resolution's common case).
fn pick_value(table: &NodeValueTable, data_type: Option<ValueType>) -> NodeValue {
if data_type == Some(ValueType::Texture) {
return table
.get(ValueType::Texture)
.cloned()
.unwrap_or(NodeValue::None);
}
table
.get(ValueType::Float)
.or_else(|| table.get(ValueType::Int))
.or_else(|| table.get(ValueType::Color))
.or_else(|| table.get(ValueType::Vec2))
.or_else(|| table.get(ValueType::Vec3))
.or_else(|| table.get(ValueType::Vec4))
.or_else(|| table.get(ValueType::Boolean))
.or_else(|| table.get(ValueType::Rational))
.or_else(|| table.get(ValueType::Text))
.or_else(|| table.get(ValueType::Combo))
.or_else(|| table.get(ValueType::StrCombo))
.or_else(|| table.get(ValueType::Texture))
.cloned()
.unwrap_or(NodeValue::None)
}
/// A value database: per-node input rows over a time range (C++
/// `NodeValueDatabase`), exposed by the traverser ffi family.
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);
}
}