render: apply clip effect stacks in the montage path
CI / Build & test (Windows) (push) Failing after 16m26s
CI / Build & test (Linux) (push) Successful in 19m12s

Adding an effect to a clip did nothing: the sequence render is
flattened into a montage (decode + composite), and MontageClip carried
no effect data at all.

- MontageClip gains an ordered effect stack (type id / enabled /
  effect input / parameter values); protocol v2 carries it as an
  additive wire field (older peers default to an empty stack).
- renderops::video_montage fills the stack from the effect chain
  (the footage source node — the chain end without an effect input —
  is dropped; the montage decodes the footage itself). Export
  (oak-task) and the multicam single-track montage fill it too.
- The worker applies the stack between decode and composite: built-in
  Opacity gets a CPU evaluator (C++ opacity.frag parity — whole vec4,
  alpha included, unity pass-through); everything else dispatches as an
  OFX plugin job through a new instance-factory slot (oak-plugin
  lazily creates + caches one instance per identifier per render
  process) with the montage's parameters injected. Disabled effects
  bypass (the C++ traverser pushes the effect input through). Unknown
  types warn once per type id and pass through — no silent no-ops.

Not covered (explicitly): Transform/Crop and the other ~30 built-in
effects have no CPU evaluator in oak-render (they pass through with a
warning), keyframed parameter animation, audio effect chains, and the
CLI's simplified montage.

Acceptance: a real 50% Opacity on real media quarters the rendered
pixels both in-process (renderops test) and through a real worker
process over IPC + shared memory (procpool_integration test);
disabling restores the plain render byte-for-byte.
This commit is contained in:
2026-08-25 04:49:00 +08:00
parent 28f4ed655e
commit f2aab8ce15
10 changed files with 926 additions and 2 deletions
+89 -1
View File
@@ -61,7 +61,7 @@ use oak_render::procpool::{
bgra8_to_f32_rgba, DispatcherConfig, ProcessDispatcher, ShmAudioRef, ShmFrameRef,
};
use oak_render::ticket::{
ticket_kind, AudioTicketParams, MontageClip, TicketArena, TicketId, TicketPayload,
ticket_kind, AudioTicketParams, MontageClip, MontageEffect, TicketArena, TicketId, TicketPayload,
TicketResult, VideoTicketParams,
};
use oak_render::worker::JobDispatch;
@@ -245,6 +245,90 @@ impl RenderTask {
}
}
/// The effect stack of a clip block as montage effect descriptors
/// (source-first order). Mirrors the app's `effectchain` walk —
/// oak-task cannot link oak-app, so the export path keeps its own
/// copy over the public oaknode graph API. Disabled effects ride
/// along with `enabled = false`; the worker bypasses them (the C++
/// traverser's bypass pushes the effect input through unchanged).
/// Parameters are the non-hidden, non-connection data inputs at
/// their standard (non-keyframed) values.
fn clip_effects(graph: &oak_node::graph::Graph, host: oak_node::id::NodeId) -> Vec<MontageEffect> {
// Walk the chain: from the host's effect input upstream until an
// unconnected input or a node without an effect input; a `seen`
// guard protects against malformed cycles.
let mut chain = Vec::new();
let mut cur = host;
let mut seen: Vec<oak_node::id::NodeId> = Vec::new();
loop {
if seen.contains(&cur) {
break;
}
seen.push(cur);
let Some(entry) = graph.get(cur) else {
break;
};
let input = &entry.core.effect_input;
if input.is_empty() {
break;
}
let Some(up) = graph.connected_output(cur, input, -1) else {
break;
};
chain.push(up);
cur = up;
}
chain.reverse();
chain
.into_iter()
// The walk runs all the way to the media source; drop the
// source node (the one WITHOUT an effect input) — the montage
// decodes the footage itself.
.filter(|&fx| {
graph
.get(fx)
.map(|e| !e.core.effect_input.is_empty())
.unwrap_or(false)
})
.filter_map(|fx| {
let entry = graph.get(fx)?;
let enabled = matches!(
entry.core.standard_value(oak_node::node::ENABLED_INPUT, -1),
oak_node::value::NodeValue::Boolean(true)
);
let effect_input_id = if entry.core.effect_input.is_empty() {
None
} else {
Some(entry.core.effect_input.clone())
};
let mut params = Vec::new();
for input in &entry.core.inputs {
if matches!(
input.value_type,
oak_node::value::ValueType::Texture
| oak_node::value::ValueType::Samples
| oak_node::value::ValueType::Matrix
) {
continue;
}
if input.flags & oak_node::input::flags::HIDDEN != 0 {
continue;
}
if input.id == oak_node::node::ENABLED_INPUT {
continue;
}
params.push((input.id.clone(), entry.core.standard_value(&input.id, -1)));
}
Some(MontageEffect {
type_id: entry.behavior.type_id().to_string(),
enabled,
effect_input_id,
params,
})
})
.collect()
}
/// Flatten the video tracks of `sequence` into an ordered montage
/// (bottom-most track first so the topmost track composites last;
/// `// CPP-PARITY: M12 P0 montage contract`).
@@ -316,6 +400,7 @@ impl RenderTask {
out_time: core.out(),
media_in: core.media_in,
gain: 1.0,
effects: Self::clip_effects(&guard.graph, *block_id),
});
}
}
@@ -393,6 +478,9 @@ impl RenderTask {
out_time: core.out(),
media_in: core.media_in,
gain: 1.0,
// Audio clips carry no effect stack (video-side
// concept; the mixer never consults it).
effects: Vec::new(),
});
}
}