node: virtual graph endpoints and the Kahn-order BFS sweep (M0b core)

Per docs/zh/plans/render-pipeline-threads.md §3.8:

- oak-node/nodes/graphendpoints.rs: the GraphInput/GraphOutput
  virtual node pair — factory-registered but hidden from every create
  menu, duplicate refused, real value() semantics (the input forwards
  its feed_in row, the output publishes its tex_in as the frame).
  The input endpoint also declares a connectable feed_in port
  (documented deviation: footage/generator sources have no connectable
  inputs, so the walk needs a feeder anchor).
- graph.rs: ensure_endpoints/endpoints/is_endpoint — idempotent,
  identified by type id, default input->output edge only while the
  output's tex_in is free; remove_node refuses endpoints.
- project.rs + serializer.rs: every project graph carries the pair;
  a legacy file without endpoints migrates on load (roundtrip and
  legacy-migration tests, re-save is idempotent).
- traverser.rs: eval_graph_bfs — the endpoint-to-endpoint Kahn
  sweep. Live set = (input's forward cone U its feeder cone) INTERSECT
  (output's backward cone); multi-input nodes dequeue at zero
  in-degree over the live subgraph; deterministic ascending-id ready
  order (Graph::edges is a BTreeSet, so insertion order is
  unrecoverable — documented); time-shifted upstreams pull through
  the shared DFS memo (walk_dfs, factored out of evaluate);
  un-orderable remainder reports a named cycle; missing endpoints /
  unreachable output are errors. Eight BFS tests cover the plan's
  acceptance bullets.
- oak-render: bfs_endpoint_sweep_renders_footage_through_position —
  real clip through a real Position node via the sweep, shifted
  pixels asserted against a reference decode.
- Endpoint names localized in all eight i18n packs; storage/structure
  tests updated for the two extra nodes.
This commit is contained in:
2026-09-11 15:13:53 +08:00
parent 3a48dd4991
commit 29204d1f63
24 changed files with 1849 additions and 81 deletions
+396
View File
@@ -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());
}
}
+8
View File
@@ -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.
+12 -6
View File
@@ -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,
+10 -7
View File
@@ -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();