render: real texture binding, generator layers and iteration feedback in shader passes
- process_shader_job: bind all texture params by name, recurse into nested shader payloads (depth cap 8), fall back to frame size without inputs - run_effect: take iterative_input so dropshadow previous_iteration_in works - merge: actually composite inputs; keyer mask, opacity modulation, math texture ops and mrg generator layers now bind their textures - transform distort: real fragment-side inverse-matrix sampling - time offset / time remap: wire NodeBehavior time adjustment hooks - plugin: fix first-node identity colliding with unbound sentinel
This commit is contained in:
@@ -337,6 +337,7 @@ impl NodeBehavior for ClipBlockBehavior {
|
||||
/// in-point. Other inputs pass through unchanged.
|
||||
fn input_time_adjustment(
|
||||
&self,
|
||||
_core: &NodeCore,
|
||||
input: &str,
|
||||
_element: i32,
|
||||
time: TimeRange,
|
||||
@@ -666,7 +667,9 @@ mod tests {
|
||||
#[test]
|
||||
fn clip_input_time_adjustment_maps_timeline_to_media() {
|
||||
let time = TimeRange::new(Rational::new(12, 1), Rational::new(13, 1));
|
||||
let map = |c: &ClipBlockBehavior| c.input_time_adjustment(clip_input::TEXTURE_INPUT, -1, time, false);
|
||||
let map = |c: &ClipBlockBehavior| {
|
||||
c.input_time_adjustment(&NodeCore::new(), clip_input::TEXTURE_INPUT, -1, time, false)
|
||||
};
|
||||
|
||||
// Speed 1: media = (12 - 10) + 5 = 7.
|
||||
assert_eq!(
|
||||
@@ -695,7 +698,10 @@ mod tests {
|
||||
TimeRange::new(Rational::new(11, 1), Rational::new(12, 1))
|
||||
);
|
||||
// Non-`tex_in` inputs pass through untouched.
|
||||
assert_eq!(clip_with(2.0, true).input_time_adjustment("other_in", -1, time, false), time);
|
||||
assert_eq!(
|
||||
clip_with(2.0, true).input_time_adjustment(&NodeCore::new(), "other_in", -1, time, false),
|
||||
time
|
||||
);
|
||||
}
|
||||
|
||||
/// The clip copies the connected `tex_in` texture to its output;
|
||||
|
||||
@@ -27,12 +27,16 @@ use crate::nodes::plugin::PluginJobPayload;
|
||||
use crate::value::NodeValueRow;
|
||||
|
||||
/// Job types
|
||||
///
|
||||
/// The payloads travel boxed inside `Texture` values during graph
|
||||
/// evaluation (the renderer probes the box's payload type at resolve
|
||||
/// time); the graph-shaped job model lands in v0.6.
|
||||
|
||||
pub enum Job{
|
||||
FootageJob(FootageJobPayload),
|
||||
ShaderJob(ShaderJobPayload),
|
||||
PluginJob(PluginJobPayload),
|
||||
ColorTransformJob
|
||||
ColorTransformJob(ColorTransformJobPayload)
|
||||
}
|
||||
|
||||
/// C++ `FootageJob` payload: the decode request a footage node emits at
|
||||
@@ -77,9 +81,19 @@ pub struct ShaderJobPayload {
|
||||
pub iterative_input: String,
|
||||
}
|
||||
|
||||
/// C++ `ColorTransformJob` payload: the OCIO processor application a
|
||||
/// color node emits at its output. The processor is shared by `Arc` —
|
||||
/// the node keeps its cached processor while in-flight jobs reference
|
||||
/// the same immutable instance. The input texture value rides along
|
||||
/// (C++ `t->to_job(job)` wraps the texture the job applies to).
|
||||
#[derive(Clone)]
|
||||
pub struct ColorTransformJobPayload{
|
||||
pub color_processor: ColorProcessor,
|
||||
|
||||
/// The OCIO processor to apply (C++ `ColorTransformJob::processor`).
|
||||
pub color_processor: std::sync::Arc<ColorProcessor>,
|
||||
/// The input texture value (C++ the texture `to_job` was called on).
|
||||
pub input: crate::value::NodeValue,
|
||||
/// Request time in media seconds.
|
||||
pub time: Rational,
|
||||
}
|
||||
|
||||
impl Default for FootageJobPayload {
|
||||
|
||||
@@ -553,27 +553,31 @@ pub trait NodeBehavior: Send {
|
||||
|
||||
/// Time adjustment through this node (C++
|
||||
/// `input_time_adjustment()`/`output_time_adjustment()`; clips
|
||||
/// override for speed/reverse).
|
||||
/// override for speed/reverse, the time nodes for offset/remap).
|
||||
/// `core` is the node's own data — the C++ implementations read
|
||||
/// member inputs (e.g. the offset/remap value) at call time.
|
||||
fn input_time_adjustment(
|
||||
&self,
|
||||
core: &NodeCore,
|
||||
input: &str,
|
||||
element: i32,
|
||||
time: TimeRange,
|
||||
traverse: bool,
|
||||
) -> TimeRange {
|
||||
let _ = (input, element, traverse);
|
||||
let _ = (core, input, element, traverse);
|
||||
time
|
||||
}
|
||||
|
||||
/// Output-side time adjustment.
|
||||
fn output_time_adjustment(
|
||||
&self,
|
||||
core: &NodeCore,
|
||||
input: &str,
|
||||
element: i32,
|
||||
time: TimeRange,
|
||||
traverse: bool,
|
||||
) -> TimeRange {
|
||||
let _ = (input, element, traverse);
|
||||
let _ = (core, input, element, traverse);
|
||||
time
|
||||
}
|
||||
|
||||
|
||||
@@ -673,13 +673,25 @@ mod tests {
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn value_gaussian_pushes_deferred_job() {
|
||||
let (mut core, behavior) = create();
|
||||
core.set_standard_value(RADIUS_INPUT, -1, NodeValue::Float(10.0));
|
||||
let inputs = crate::value::NodeValueRow::from([(TEXTURE_INPUT.to_string(), tex())]);
|
||||
fn value_gaussian_pushes_shader_job() {
|
||||
let (core, behavior) = create();
|
||||
let inputs = crate::value::NodeValueRow::from([
|
||||
(TEXTURE_INPUT.to_string(), tex()),
|
||||
(RADIUS_INPUT.to_string(), NodeValue::Float(10.0)),
|
||||
]);
|
||||
let mut table = NodeValueTable::default();
|
||||
behavior.value(&core, &inputs, Rational::new(0, 1), &mut table);
|
||||
assert!(table.get(ValueType::Texture).is_some());
|
||||
let Some(NodeValue::Texture(h)) = table.get(ValueType::Texture) else {
|
||||
panic!("texture expected");
|
||||
};
|
||||
let payload = unsafe { crate::handle::get_checked::<crate::jobs::ShaderJobPayload>(h) }
|
||||
.expect("shader job pushed");
|
||||
assert_eq!(payload.type_id, "org.olivevideoeditor.Olive.blur");
|
||||
assert_eq!(
|
||||
payload.params.get(RADIUS_INPUT),
|
||||
Some(&NodeValue::Float(10.0)),
|
||||
"the blur radius rides in the job params"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
@@ -617,7 +617,16 @@ mod tests {
|
||||
let inputs = crate::value::NodeValueRow::from([(TEXTURE_INPUT.to_string(), tex())]);
|
||||
let mut table = NodeValueTable::default();
|
||||
behavior.value(&core, &inputs, Rational::new(0, 1), &mut table);
|
||||
assert!(table.get(ValueType::Texture).is_some());
|
||||
// A corner off its default (y-only is enough — C++ `is_null()`
|
||||
// requires BOTH components zero) pushes the job; a pass-through
|
||||
// would push the input's own (null) handle instead.
|
||||
let Some(NodeValue::Texture(h)) = table.get(ValueType::Texture) else {
|
||||
panic!("texture expected");
|
||||
};
|
||||
assert!(
|
||||
unsafe { crate::handle::get_checked::<crate::jobs::ShaderJobPayload>(h) }.is_some(),
|
||||
"a moved corner must push the cornerpin shader job"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
@@ -368,17 +368,25 @@ mod tests {
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn value_pushes_deferred_job_with_processor() {
|
||||
fn value_pushes_color_transform_job_with_processor() {
|
||||
let core = NodeCore::new();
|
||||
let mut n = node();
|
||||
n.base.set_processor(Some(crate::handle::CHandle::null()));
|
||||
let n = node();
|
||||
n.base.set_processor(Some(std::sync::Arc::new(
|
||||
oak_core::color::ColorProcessor::pass_through(),
|
||||
)));
|
||||
let inputs = crate::value::NodeValueRow::from([(
|
||||
crate::nodes::ociobase::TEXTURE_INPUT.to_string(),
|
||||
NodeValue::Texture(crate::handle::CHandle::null()),
|
||||
)]);
|
||||
let mut table = NodeValueTable::default();
|
||||
n.value(&core, &inputs, Rational::new(0, 1), &mut table);
|
||||
assert!(table.get(ValueType::Texture).is_some());
|
||||
let Some(NodeValue::Texture(handle)) = table.get(ValueType::Texture) else {
|
||||
panic!("job row expected");
|
||||
};
|
||||
assert!(unsafe {
|
||||
crate::handle::get_checked::<crate::jobs::ColorTransformJobPayload>(handle)
|
||||
}
|
||||
.is_some());
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
@@ -258,6 +258,35 @@ fn matrix_rotate_z(m: [f64; 16], degrees: f64) -> [f64; 16] {
|
||||
matrix_mul(m, r)
|
||||
}
|
||||
|
||||
/// Invert a 2D affine transform (C++ `Matrix4x4::inverted` on the 2D
|
||||
/// path). The matrices this crate generates are affine —
|
||||
/// `[a b 0 tx; c d 0 ty; 0 0 sz 0; 0 0 0 1]` in the row-major
|
||||
/// `m[r*4+c]` layout — so the inverse is analytic. `None` when the
|
||||
/// affine part is singular (a zero scale collapses the plane; the C++
|
||||
/// callers fall back to identity there).
|
||||
pub fn matrix_invert_2d(m: [f64; 16]) -> Option<[f64; 16]> {
|
||||
let (a, b, tx) = (m[0], m[1], m[3]);
|
||||
let (c, d, ty) = (m[4], m[5], m[7]);
|
||||
let det = a * d - b * c;
|
||||
if det.abs() < 1e-12 {
|
||||
return None;
|
||||
}
|
||||
let inv = 1.0 / det;
|
||||
let (ia, ib, ic, id) = (d * inv, -b * inv, -c * inv, a * inv);
|
||||
let mut out = super::mathbase::identity_matrix();
|
||||
out[0] = ia;
|
||||
out[1] = ib;
|
||||
out[3] = -(ia * tx + ib * ty);
|
||||
out[4] = ic;
|
||||
out[5] = id;
|
||||
out[7] = -(ic * tx + id * ty);
|
||||
let sz = m[10];
|
||||
if sz.abs() > 1e-12 {
|
||||
out[10] = 1.0 / sz;
|
||||
}
|
||||
Some(out)
|
||||
}
|
||||
|
||||
/// Resolve a vec2 input from the render row or the keyframed/standard
|
||||
/// value (C++ `value.at(id).to_vec2()`; missing values read as
|
||||
/// `(0, 0)`).
|
||||
|
||||
@@ -49,60 +49,60 @@ pub const TEXTURE_INPUT: &str = "tex_in";
|
||||
/// generation helpers reach the manager through
|
||||
/// `crate::colormanager`/oakrender at call time.
|
||||
pub struct OcioBase {
|
||||
/// Owned color processor handle (C++ `processor_`, an
|
||||
/// `OakColorProcessor`); `None`/empty while no valid processor has
|
||||
/// been generated. Released with the node (C++ destructor calls
|
||||
/// `oakrender_color_processor_free`).
|
||||
processor: Option<crate::handle::CHandle>,
|
||||
/// Owned color processor (C++ `processor_`, an `OakColorProcessor`),
|
||||
/// shared by `Arc` because the [`crate::jobs::ColorTransformJobPayload`]
|
||||
/// emitted at evaluation time carries the same immutable processor.
|
||||
/// `None` while no valid processor has been generated. Released with
|
||||
/// the node (C++ destructor calls `oakrender_color_processor_free`).
|
||||
///
|
||||
/// Behind a mutex because the C++ regenerates the processor from
|
||||
/// `value()`-time paths (`ensure_processor()`'s mutable-in-const-
|
||||
/// method pattern), so interior mutability is required.
|
||||
processor: std::sync::Mutex<Option<std::sync::Arc<oak_core::color::ColorProcessor>>>,
|
||||
}
|
||||
|
||||
// The processor handle wraps a refcounted C object that is only
|
||||
// dereferenced from the render path (the C++ base likewise passes its
|
||||
// `OakColorProcessor` across threads by value); moving the struct
|
||||
// between threads does not introduce sharing the C++ side does not
|
||||
// already have.
|
||||
unsafe impl Send for OcioBase {}
|
||||
|
||||
impl OcioBase {
|
||||
/// Construct the shared base state (C++ `OCIOBaseNode::OCIOBaseNode()`):
|
||||
/// the processor starts empty; the constructor side that adds
|
||||
/// [`TEXTURE_INPUT`], marks it the effect input and sets the
|
||||
/// video-effect flag happens in each node's `create()`.
|
||||
pub fn new() -> Self {
|
||||
OcioBase { processor: None }
|
||||
OcioBase {
|
||||
processor: std::sync::Mutex::new(None),
|
||||
}
|
||||
}
|
||||
|
||||
/// Borrowed view of the owned processor handle (C++
|
||||
/// Shared reference to the owned processor (C++
|
||||
/// `OCIOBaseNode::processor()`; callers must NOT free it).
|
||||
pub fn processor(&self) -> Option<&crate::handle::CHandle> {
|
||||
self.processor.as_ref()
|
||||
pub fn processor(&self) -> Option<std::sync::Arc<oak_core::color::ColorProcessor>> {
|
||||
self.processor
|
||||
.lock()
|
||||
.unwrap_or_else(|e| e.into_inner())
|
||||
.clone()
|
||||
}
|
||||
|
||||
/// Take ownership of a new processor handle, releasing the old one
|
||||
/// Take ownership of a new processor, releasing the old one
|
||||
/// (C++ `OCIOBaseNode::set_processor()`, which frees the previous
|
||||
/// `OakColorProcessor` before storing the new one).
|
||||
pub fn set_processor(
|
||||
&mut self,
|
||||
processor: Option<crate::handle::CHandle>,
|
||||
&self,
|
||||
processor: Option<std::sync::Arc<oak_core::color::ColorProcessor>>,
|
||||
) {
|
||||
// The C++ frees the previous processor via
|
||||
// `oakrender_color_processor_free`; the Rust handle is a
|
||||
// refcounted `CHandle` released on drop, so replacing the field
|
||||
// drops the old one automatically.
|
||||
self.processor = processor;
|
||||
// `oakrender_color_processor_free`; the Rust processor is an
|
||||
// `Arc` released on drop, so replacing the field drops the old
|
||||
// one automatically once any in-flight jobs release it.
|
||||
*self.processor.lock().unwrap_or_else(|e| e.into_inner()) = processor;
|
||||
}
|
||||
|
||||
/// Shared output evaluation (C++ `OCIOBaseNode::value()`): no texture
|
||||
/// on [`TEXTURE_INPUT`] -> push nothing; texture present and
|
||||
/// processor ready -> push a `ColorTransformJob` built from the
|
||||
/// processor and the input texture; texture present but processor not
|
||||
/// ready (e.g. still being generated asynchronously) -> pass the
|
||||
/// input texture through unchanged.
|
||||
///
|
||||
/// The Rust model has no color-transform job payload: the ready case
|
||||
/// pushes a null texture handle marking a renderer-deferred job
|
||||
/// (the C++ `t->to_job(ColorTransformJob)` resolved by the renderer
|
||||
/// via the processor); the not-ready case pushes the input texture.
|
||||
/// processor ready -> push a boxed
|
||||
/// [`crate::jobs::ColorTransformJobPayload`] built from the processor
|
||||
/// and the input texture (the C++ `t->to_job(ColorTransformJob)`,
|
||||
/// resolved by the render seam via the processor); texture present
|
||||
/// but processor not ready (e.g. still being generated
|
||||
/// asynchronously) -> pass the input texture through unchanged.
|
||||
/// `// CPP-PARITY: ociobase.cpp` `value()`.
|
||||
pub fn value(
|
||||
&self,
|
||||
@@ -111,20 +111,23 @@ impl OcioBase {
|
||||
time: oak_core::Rational,
|
||||
table: &mut NodeValueTable,
|
||||
) {
|
||||
let _ = (core, time);
|
||||
match inputs.get(TEXTURE_INPUT) {
|
||||
Some(tex @ NodeValue::Texture(_)) => {
|
||||
if self.processor.is_some() {
|
||||
table.push(
|
||||
crate::value::ValueType::Texture,
|
||||
NodeValue::Texture(crate::handle::CHandle::null()),
|
||||
None,
|
||||
);
|
||||
} else {
|
||||
table.push(crate::value::ValueType::Texture, tex.clone(), None);
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
let _ = core;
|
||||
let Some(tex @ NodeValue::Texture(_)) = inputs.get(TEXTURE_INPUT) else {
|
||||
return;
|
||||
};
|
||||
match self.processor() {
|
||||
Some(processor) => table.push(
|
||||
crate::value::ValueType::Texture,
|
||||
NodeValue::Texture(crate::handle::make_owned(
|
||||
crate::jobs::ColorTransformJobPayload {
|
||||
color_processor: processor,
|
||||
input: tex.clone(),
|
||||
time,
|
||||
},
|
||||
)),
|
||||
None,
|
||||
),
|
||||
None => table.push(crate::value::ValueType::Texture, tex.clone(), None),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -156,9 +159,11 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn processor_state_transitions() {
|
||||
let mut base = OcioBase::new();
|
||||
let base = OcioBase::new();
|
||||
assert!(base.processor().is_none());
|
||||
base.set_processor(Some(crate::handle::CHandle::null()));
|
||||
base.set_processor(Some(std::sync::Arc::new(
|
||||
oak_core::color::ColorProcessor::pass_through(),
|
||||
)));
|
||||
assert!(base.processor().is_some());
|
||||
base.set_processor(None);
|
||||
assert!(base.processor().is_none());
|
||||
@@ -194,21 +199,35 @@ mod tests {
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn value_pushes_deferred_job_with_processor() {
|
||||
let mut base = OcioBase::new();
|
||||
base.set_processor(Some(crate::handle::CHandle::null()));
|
||||
fn value_pushes_color_transform_job_with_processor() {
|
||||
let base = OcioBase::new();
|
||||
base.set_processor(Some(std::sync::Arc::new(
|
||||
oak_core::color::ColorProcessor::pass_through(),
|
||||
)));
|
||||
let mut table = NodeValueTable::default();
|
||||
let inputs = NodeValueRow::from([(
|
||||
TEXTURE_INPUT.to_string(),
|
||||
NodeValue::Texture(crate::handle::CHandle::null()),
|
||||
)]);
|
||||
let tex = NodeValue::Texture(crate::handle::make_owned(1u8));
|
||||
let inputs = NodeValueRow::from([(TEXTURE_INPUT.to_string(), tex.clone())]);
|
||||
base.value(
|
||||
&NodeCore::new(),
|
||||
&inputs,
|
||||
oak_core::Rational::new(0, 1),
|
||||
&mut table,
|
||||
);
|
||||
assert!(table.get(ValueType::Texture).is_some());
|
||||
// The ready case pushes the boxed ColorTransformJobPayload (the
|
||||
// C++ `t->to_job(ColorTransformJob)`).
|
||||
let Some(NodeValue::Texture(handle)) = table.get(ValueType::Texture) else {
|
||||
panic!("job row expected");
|
||||
};
|
||||
let payload = unsafe {
|
||||
crate::handle::get_checked::<crate::jobs::ColorTransformJobPayload>(handle)
|
||||
}
|
||||
.expect("a boxed ColorTransformJobPayload");
|
||||
assert!(std::sync::Arc::ptr_eq(
|
||||
&payload.color_processor,
|
||||
&base.processor().unwrap()
|
||||
));
|
||||
assert_eq!(payload.input, tex);
|
||||
assert_eq!(payload.time, oak_core::Rational::new(0, 1));
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
@@ -56,8 +56,8 @@ struct ProcessorState {
|
||||
/// `last_direction_`, starts `-1`).
|
||||
last_direction: i64,
|
||||
/// Cached processor for change detection (C++ `last_processor_`);
|
||||
/// released with the node.
|
||||
last_processor: Option<crate::handle::CHandle>,
|
||||
/// shared with the base's active processor slot.
|
||||
last_processor: Option<std::sync::Arc<oak_core::color::ColorProcessor>>,
|
||||
/// Human-readable reason no LUT processor is active (C++
|
||||
/// `last_error_`); empty when a valid processor is in use or no LUT
|
||||
/// file has been selected yet.
|
||||
@@ -77,10 +77,6 @@ impl Default for ProcessorState {
|
||||
}
|
||||
}
|
||||
|
||||
// The cached processor handle wraps a refcounted C object that is only
|
||||
// dereferenced from the render path; see `OcioBase` for the rationale.
|
||||
unsafe impl Send for ProcessorState {}
|
||||
|
||||
/// OCIO LUT node. Applies a LUT file through OpenColorIO.
|
||||
pub struct OCIOLutNode {
|
||||
/// Shared OCIO base state (C++ base class `OCIOBaseNode`).
|
||||
@@ -175,7 +171,7 @@ impl OCIOLutNode {
|
||||
{
|
||||
let state = self.state.lock().unwrap();
|
||||
if !state.dirty
|
||||
&& state.last_processor.as_ref().is_some_and(|p| !p.is_null())
|
||||
&& state.last_processor.is_some()
|
||||
&& Self::file_path(core) == state.last_path
|
||||
&& Self::read_direction_input(core) == state.last_direction
|
||||
{
|
||||
@@ -186,34 +182,63 @@ impl OCIOLutNode {
|
||||
}
|
||||
|
||||
/// Rebuild the processor from the LUT path and direction (C++
|
||||
/// `create_processor_from_inputs()`): no manager, empty path,
|
||||
/// non-regular file, or unsupported extension -> clear both
|
||||
/// processors, reset the cache markers, record the error, and return
|
||||
/// false; unchanged path+direction with a live cached processor ->
|
||||
/// clear the dirty flag and return false (reuse); otherwise create
|
||||
/// the LUT processor via `oakrender_color_processor_create_lut`
|
||||
/// (direction 0 = forward), update the cache markers and both
|
||||
/// processor slots, and return true.
|
||||
/// `create_processor_from_inputs()`): empty path, non-regular file,
|
||||
/// or a failed OCIO FileTransform -> clear both processor slots,
|
||||
/// reset the cache markers, record the error, and return false;
|
||||
/// otherwise create the LUT processor via
|
||||
/// [`oak_core::color::ColorProcessor::create_lut`] on the process-wide
|
||||
/// default config (direction 0 = forward), update the cache markers
|
||||
/// and both processor slots, and return true. A missing default
|
||||
/// config yields `None` from `create_lut`, landing in the error
|
||||
/// branch (the node then passes its input through unchanged).
|
||||
/// `// CPP-PARITY: ociolut.cpp` `create_processor_from_inputs()`.
|
||||
fn create_processor_from_inputs(&self, core: &NodeCore) -> bool {
|
||||
let _ = core;
|
||||
let mut state = self.state.lock().unwrap();
|
||||
let path = Self::file_path(core);
|
||||
let direction = Self::read_direction_input(core);
|
||||
|
||||
// C++ branch 1: no color manager. The Rust model reaches the
|
||||
// manager through the oakrender bridge (absent here), so this
|
||||
// branch is always taken: reset the cache markers and report
|
||||
// false. The C++ additionally clears the standard processor and
|
||||
// frees `last_processor_` — the Rust base processor is only
|
||||
// reachable through `&mut self` and can never hold a processor
|
||||
// without the render bridge, so those clears are no-ops here.
|
||||
// The empty-path/non-regular-file/unsupported-extension error
|
||||
// branches are unreachable without a manager and are not
|
||||
// representable. `// CPP-PARITY: ociolut.cpp`
|
||||
// create_processor_from_inputs.
|
||||
state.last_processor = None;
|
||||
state.last_path.clear();
|
||||
state.last_direction = -1;
|
||||
state.dirty = false;
|
||||
false
|
||||
// Reset both processor slots and the cache markers, recording
|
||||
// `error` (empty = no error: no LUT selected yet).
|
||||
let clear = |node: &Self, error: String| {
|
||||
let mut state = node.state.lock().unwrap();
|
||||
state.last_processor = None;
|
||||
state.last_path.clear();
|
||||
state.last_direction = -1;
|
||||
state.dirty = false;
|
||||
state.last_error = error;
|
||||
node.base.set_processor(None);
|
||||
false
|
||||
};
|
||||
|
||||
if path.is_empty() {
|
||||
return clear(self, String::new());
|
||||
}
|
||||
if !std::path::Path::new(&path).is_file() {
|
||||
return clear(self, format!("LUT file not found: {path}"));
|
||||
}
|
||||
let dir = if direction == 1 {
|
||||
oak_core::color::Direction::Inverse
|
||||
} else {
|
||||
oak_core::color::Direction::Normal
|
||||
};
|
||||
match oak_core::color::ColorProcessor::create_lut(&path, dir) {
|
||||
Some(processor) => {
|
||||
let processor = std::sync::Arc::new(processor);
|
||||
{
|
||||
let mut state = self.state.lock().unwrap();
|
||||
state.last_processor = Some(processor.clone());
|
||||
state.last_path = path;
|
||||
state.last_direction = direction;
|
||||
state.dirty = false;
|
||||
state.last_error.clear();
|
||||
}
|
||||
self.base.set_processor(Some(processor));
|
||||
true
|
||||
}
|
||||
None => clear(
|
||||
self,
|
||||
format!("Failed to create a LUT processor from {path}"),
|
||||
),
|
||||
}
|
||||
}
|
||||
|
||||
/// OCIO config change hook (C++ `config_changed()` override):
|
||||
@@ -506,7 +531,7 @@ mod tests {
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn create_processor_from_inputs_resets_markers_without_manager() {
|
||||
fn create_processor_from_inputs_resets_markers_without_lut() {
|
||||
let n = node();
|
||||
let core = NodeCore::new();
|
||||
{
|
||||
@@ -514,9 +539,13 @@ mod tests {
|
||||
state.dirty = true;
|
||||
state.last_path = "/tmp/foo.cube".to_string();
|
||||
state.last_direction = 1;
|
||||
state.last_processor = Some(crate::handle::CHandle::null());
|
||||
state.last_processor = Some(std::sync::Arc::new(
|
||||
oak_core::color::ColorProcessor::pass_through(),
|
||||
));
|
||||
state.last_error = "stale error".to_string();
|
||||
}
|
||||
// No FILE_INPUT on the core -> empty path branch: markers reset
|
||||
// and the stale error clears (no LUT selected is not an error).
|
||||
let created = n.create_processor_from_inputs(&core);
|
||||
assert!(!created);
|
||||
let state = n.state.lock().unwrap();
|
||||
@@ -524,8 +553,7 @@ mod tests {
|
||||
assert_eq!(state.last_path, "");
|
||||
assert_eq!(state.last_direction, -1);
|
||||
assert!(state.last_processor.is_none());
|
||||
// The no-manager branch does not touch the recorded error (C++).
|
||||
assert_eq!(state.last_error, "stale error");
|
||||
assert_eq!(state.last_error, "");
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -600,7 +628,9 @@ mod tests {
|
||||
state.dirty = false;
|
||||
state.last_path = "/tmp/x.cube".to_string();
|
||||
state.last_direction = 1;
|
||||
state.last_processor = Some(crate::handle::make_owned::<u8>(1));
|
||||
state.last_processor = Some(std::sync::Arc::new(
|
||||
oak_core::color::ColorProcessor::pass_through(),
|
||||
));
|
||||
}
|
||||
// Unchanged path+direction with a live processor: early return,
|
||||
// markers are preserved (create_processor_from_inputs would have
|
||||
@@ -660,17 +690,45 @@ mod tests {
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn value_pushes_deferred_job_with_processor() {
|
||||
let core = NodeCore::new();
|
||||
let mut n = node();
|
||||
n.base.set_processor(Some(crate::handle::CHandle::null()));
|
||||
fn value_pushes_color_transform_job_with_processor() {
|
||||
let mut core = NodeCore::new();
|
||||
core.add_input(crate::input::Input::new(
|
||||
FILE_INPUT,
|
||||
crate::value::ValueType::Text,
|
||||
crate::value::NodeValue::Text(String::new()),
|
||||
));
|
||||
core.add_input(crate::input::Input::new(
|
||||
DIRECTION_INPUT,
|
||||
crate::value::ValueType::Combo,
|
||||
crate::value::NodeValue::Combo(0),
|
||||
));
|
||||
core.set_standard_value(FILE_INPUT, -1, NodeValue::Text("/tmp/x.cube".into()));
|
||||
core.set_standard_value(DIRECTION_INPUT, -1, NodeValue::Combo(1));
|
||||
let n = node();
|
||||
let processor = std::sync::Arc::new(oak_core::color::ColorProcessor::pass_through());
|
||||
{
|
||||
// Prime the cache so value()'s ensure_processor early-returns
|
||||
// (unchanged path/direction, live cached processor).
|
||||
let mut state = n.state.lock().unwrap();
|
||||
state.dirty = false;
|
||||
state.last_path = "/tmp/x.cube".to_string();
|
||||
state.last_direction = 1;
|
||||
state.last_processor = Some(processor.clone());
|
||||
}
|
||||
n.base.set_processor(Some(processor));
|
||||
let inputs = crate::value::NodeValueRow::from([(
|
||||
crate::nodes::ociobase::TEXTURE_INPUT.to_string(),
|
||||
NodeValue::Texture(crate::handle::CHandle::null()),
|
||||
)]);
|
||||
let mut table = NodeValueTable::default();
|
||||
n.value(&core, &inputs, Rational::new(0, 1), &mut table);
|
||||
assert!(table.get(ValueType::Texture).is_some());
|
||||
let Some(NodeValue::Texture(handle)) = table.get(ValueType::Texture) else {
|
||||
panic!("job row expected");
|
||||
};
|
||||
assert!(unsafe {
|
||||
crate::handle::get_checked::<crate::jobs::ColorTransformJobPayload>(handle)
|
||||
}
|
||||
.is_some());
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
@@ -308,7 +308,7 @@ mod tests {
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn value_opacity_scaled_pushes_job_placeholder() {
|
||||
fn value_opacity_scaled_pushes_shader_job() {
|
||||
let (mut core, behavior) = create();
|
||||
core.set_standard_value(VALUE_INPUT, -1, NodeValue::Float(0.5));
|
||||
let inputs = crate::value::NodeValueRow::from([(
|
||||
@@ -317,11 +317,20 @@ mod tests {
|
||||
)]);
|
||||
let mut table = NodeValueTable::default();
|
||||
behavior.value(&core, &inputs, Rational::new(0, 1), &mut table);
|
||||
assert!(table.get(ValueType::Texture).is_some());
|
||||
// The scaled-opacity path pushes the default-variant shader job
|
||||
// (a pass-through would push the input's null handle).
|
||||
let Some(NodeValue::Texture(h)) = table.get(ValueType::Texture) else {
|
||||
panic!("texture expected");
|
||||
};
|
||||
let payload = unsafe { crate::handle::get_checked::<crate::jobs::ShaderJobPayload>(h) }
|
||||
.expect("shader job pushed");
|
||||
assert_eq!(payload.type_id, "org.olivevideoeditor.Olive.opacity");
|
||||
assert_eq!(payload.shader_id, "");
|
||||
assert_eq!(payload.effect_input, TEXTURE_INPUT);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn value_opacity_in_row_scaled_pushes_job_placeholder() {
|
||||
fn value_opacity_in_row_scaled_pushes_shader_job() {
|
||||
let (core, behavior) = create();
|
||||
let inputs = crate::value::NodeValueRow::from([
|
||||
(
|
||||
@@ -332,7 +341,17 @@ mod tests {
|
||||
]);
|
||||
let mut table = NodeValueTable::default();
|
||||
behavior.value(&core, &inputs, Rational::new(0, 1), &mut table);
|
||||
assert!(table.get(ValueType::Texture).is_some());
|
||||
let Some(NodeValue::Texture(h)) = table.get(ValueType::Texture) else {
|
||||
panic!("texture expected");
|
||||
};
|
||||
let payload = unsafe { crate::handle::get_checked::<crate::jobs::ShaderJobPayload>(h) }
|
||||
.expect("shader job pushed");
|
||||
assert_eq!(payload.type_id, "org.olivevideoeditor.Olive.opacity");
|
||||
assert_eq!(
|
||||
payload.params.get(VALUE_INPUT),
|
||||
Some(&NodeValue::Float(0.5)),
|
||||
"the opacity value rides in the job params"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -349,7 +368,7 @@ mod tests {
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn value_texture_opacity_pushes_rgbmult_placeholder() {
|
||||
fn value_texture_opacity_pushes_rgbmult_job() {
|
||||
let (core, behavior) = create();
|
||||
let inputs = crate::value::NodeValueRow::from([
|
||||
(
|
||||
@@ -363,7 +382,13 @@ mod tests {
|
||||
]);
|
||||
let mut table = NodeValueTable::default();
|
||||
behavior.value(&core, &inputs, Rational::new(0, 1), &mut table);
|
||||
assert!(table.get(ValueType::Texture).is_some());
|
||||
// A texture opacity input selects the rgbmult shader variant.
|
||||
let Some(NodeValue::Texture(h)) = table.get(ValueType::Texture) else {
|
||||
panic!("texture expected");
|
||||
};
|
||||
let payload = unsafe { crate::handle::get_checked::<crate::jobs::ShaderJobPayload>(h) }
|
||||
.expect("shader job pushed");
|
||||
assert_eq!(payload.shader_id, "rgbmult");
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
@@ -487,21 +487,31 @@ mod tests {
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn value_resolves_source_clip_first() {
|
||||
fn value_pushes_job_for_source_clip_only_input() {
|
||||
// Gate: a plugin with only the simple-source clip connected still
|
||||
// emits its job (the C++ resolution tries `Source` before
|
||||
// `tex_in`). With no texture at all, nothing is pushed.
|
||||
let n = node();
|
||||
let core = NodeCore::new();
|
||||
|
||||
let mut row = NodeValueRow::default();
|
||||
row.insert(
|
||||
SOURCE_CLIP.to_string(),
|
||||
NodeValue::Texture(crate::handle::CHandle::null()),
|
||||
);
|
||||
row.insert(
|
||||
TEXTURE_INPUT.to_string(),
|
||||
NodeValue::Texture(crate::handle::CHandle::null()),
|
||||
);
|
||||
let mut table = NodeValueTable::default();
|
||||
n.value(&core, &row, Rational::new(0, 1), &mut table);
|
||||
assert!(matches!(table.get(ValueType::Texture), Some(NodeValue::Texture(_))));
|
||||
assert!(
|
||||
matches!(table.get(ValueType::Texture), Some(NodeValue::Texture(h)) if !h.is_null()),
|
||||
"a source-clip-only row must still produce the plugin job"
|
||||
);
|
||||
|
||||
let mut table = NodeValueTable::default();
|
||||
n.value(&core, &NodeValueRow::default(), Rational::new(0, 1), &mut table);
|
||||
assert!(
|
||||
table.get(ValueType::Texture).is_none(),
|
||||
"no texture anywhere -> no job"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
@@ -459,7 +459,7 @@ mod tests {
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn value_with_base_merges() {
|
||||
fn value_with_base_pushes_merge_job_with_nested_blend() {
|
||||
let (core, behavior) = create();
|
||||
let inputs = crate::value::NodeValueRow::from([(
|
||||
super::super::generatorwithmerge::BASE_INPUT.to_string(),
|
||||
@@ -467,7 +467,25 @@ mod tests {
|
||||
)]);
|
||||
let mut table = NodeValueTable::default();
|
||||
behavior.value(&core, &inputs, Rational::new(0, 1), &mut table);
|
||||
assert!(table.get(ValueType::Texture).is_some());
|
||||
// The base-merge path pushes the "mrg" alpha-over job whose
|
||||
// blend_in is the generator's own "rgb" job nested as a texture
|
||||
// value (same shape as shapenode's merge test).
|
||||
let Some(NodeValue::Texture(h)) = table.get(ValueType::Texture) else {
|
||||
panic!("texture expected");
|
||||
};
|
||||
let mrg = unsafe { crate::handle::get_checked::<crate::jobs::ShaderJobPayload>(h) }
|
||||
.expect("merge job pushed");
|
||||
assert_eq!(mrg.shader_id, "mrg");
|
||||
assert_eq!(mrg.effect_input, super::super::generatorwithmerge::BASE_INPUT);
|
||||
assert!(mrg.params.contains_key(super::super::generatorwithmerge::BASE_INPUT));
|
||||
let blend = match mrg.params.get(crate::nodes::merge::BLEND_INPUT) {
|
||||
Some(NodeValue::Texture(b)) => *b,
|
||||
other => panic!("blend_in must carry the nested job: {other:?}"),
|
||||
};
|
||||
let blend_job = unsafe { crate::handle::get_checked::<crate::jobs::ShaderJobPayload>(&blend) }
|
||||
.expect("nested generator job");
|
||||
assert_eq!(blend_job.shader_id, "rgb");
|
||||
assert_eq!(blend_job.type_id, "org.olivevideoeditor.Olive.polygon");
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
@@ -66,9 +66,9 @@ impl TimeOffsetNode {
|
||||
/// `time_in` value evaluated at that endpoint; all other inputs fall
|
||||
/// through to the base-class identity behavior.
|
||||
///
|
||||
/// The [`NodeBehavior::input_time_adjustment`] trait method carries no
|
||||
/// `NodeCore`, so this value-resolving variant is what render-time call
|
||||
/// sites (and the tests) use; the trait method documents that gap.
|
||||
/// The [`NodeBehavior::input_time_adjustment`] trait method receives the
|
||||
/// core and delegates here; kept as an associated fn so the tests can
|
||||
/// drive the mapping without a behavior instance.
|
||||
pub fn input_time_adjustment_with(
|
||||
core: &NodeCore,
|
||||
input: &str,
|
||||
@@ -146,42 +146,35 @@ impl NodeBehavior for TimeOffsetNode {
|
||||
/// `input_in`, both ends of the range are shifted forward by the current
|
||||
/// `time_in` value (C++ `get_remapped_time()`: `input + time_in`);
|
||||
/// all other inputs fall through to the base-class identity behavior.
|
||||
///
|
||||
/// The C++ evaluation reads the keyframable `time_in` input, which
|
||||
/// requires the node's data ([`NodeCore`]) — not carried by this trait
|
||||
/// signature. The exact remap is ported in
|
||||
/// [`Self::input_time_adjustment_with`] (and tested there); until the
|
||||
/// adjustment API gains core access, the identity range is returned
|
||||
/// `core` carries the node's data (the keyframable `time_in` input),
|
||||
/// matching the C++ member read
|
||||
/// (`// CPP-PARITY: timeoffsetnode.cpp` `input_time_adjustment`).
|
||||
fn input_time_adjustment(
|
||||
&self,
|
||||
core: &NodeCore,
|
||||
input: &str,
|
||||
element: i32,
|
||||
time: TimeRange,
|
||||
traverse: bool,
|
||||
) -> TimeRange {
|
||||
let _ = (input, element, traverse);
|
||||
time
|
||||
Self::input_time_adjustment_with(core, input, element, time, traverse)
|
||||
}
|
||||
|
||||
/// Output-side time remap (C++ `output_time_adjustment()`): the exact
|
||||
/// inverse of the input adjustment — for `input_in`, both ends of the
|
||||
/// range are shifted back by subtracting the `time_in` value (C++
|
||||
/// `get_remapped_output_time()`: `input - time_in`); all other inputs
|
||||
/// fall through to the base-class identity behavior.
|
||||
///
|
||||
/// As with the input side, the value read needs the node's data; the
|
||||
/// exact remap is ported in [`Self::output_time_adjustment_with`]
|
||||
/// fall through to the base-class identity behavior
|
||||
/// (`// CPP-PARITY: timeoffsetnode.cpp` `output_time_adjustment`).
|
||||
fn output_time_adjustment(
|
||||
&self,
|
||||
core: &NodeCore,
|
||||
input: &str,
|
||||
element: i32,
|
||||
time: TimeRange,
|
||||
traverse: bool,
|
||||
) -> TimeRange {
|
||||
let _ = (input, element, traverse);
|
||||
time
|
||||
Self::output_time_adjustment_with(core, input, element, time, traverse)
|
||||
}
|
||||
|
||||
/// Evaluate outputs (C++ `value()`): pushes the value arriving at
|
||||
|
||||
@@ -56,9 +56,9 @@ impl TimeRemapNode {
|
||||
/// original time); all other inputs fall through to the base-class
|
||||
/// identity behavior.
|
||||
///
|
||||
/// The [`NodeBehavior::input_time_adjustment`] trait method carries no
|
||||
/// `NodeCore`, so this value-resolving variant is what render-time call
|
||||
/// sites (and the tests) use; the trait method documents that gap.
|
||||
/// The [`NodeBehavior::input_time_adjustment`] trait method receives the
|
||||
/// core and delegates here; kept as an associated fn so the tests can
|
||||
/// drive the mapping without a behavior instance.
|
||||
pub fn input_time_adjustment_with(
|
||||
core: &NodeCore,
|
||||
input: &str,
|
||||
@@ -115,23 +115,18 @@ impl NodeBehavior for TimeRemapNode {
|
||||
/// `input_in`, both ends of the range are replaced by the `time_in`
|
||||
/// value at that time (C++ `get_remapped_time()`: `time_in` evaluated at
|
||||
/// `input`, discarding the original time); all other inputs fall through
|
||||
/// to the base-class identity behavior.
|
||||
///
|
||||
/// The C++ evaluation reads the keyframable `time_in` input, which
|
||||
/// requires the node's data ([`NodeCore`]) — not carried by this trait
|
||||
/// signature. The exact remap is ported in
|
||||
/// [`Self::input_time_adjustment_with`] (and tested there); until the
|
||||
/// adjustment API gains core access, the identity range is returned
|
||||
/// to the base-class identity behavior. `core` carries the node's data
|
||||
/// (the keyframable `time_in` input), matching the C++ member read
|
||||
/// (`// CPP-PARITY: timeremap.cpp` `input_time_adjustment`).
|
||||
fn input_time_adjustment(
|
||||
&self,
|
||||
core: &NodeCore,
|
||||
input: &str,
|
||||
element: i32,
|
||||
time: TimeRange,
|
||||
traverse: bool,
|
||||
) -> TimeRange {
|
||||
let _ = (input, element, traverse);
|
||||
time
|
||||
Self::input_time_adjustment_with(core, input, element, time, traverse)
|
||||
}
|
||||
|
||||
/// Output-side time remap (C++ `output_time_adjustment()`): the C++
|
||||
@@ -140,12 +135,13 @@ impl NodeBehavior for TimeRemapNode {
|
||||
/// base-class identity behavior; declared here for parity.
|
||||
fn output_time_adjustment(
|
||||
&self,
|
||||
core: &NodeCore,
|
||||
input: &str,
|
||||
element: i32,
|
||||
time: TimeRange,
|
||||
traverse: bool,
|
||||
) -> TimeRange {
|
||||
let _ = (input, element, traverse);
|
||||
let _ = (core, input, element, traverse);
|
||||
time
|
||||
}
|
||||
|
||||
@@ -328,8 +324,14 @@ mod tests {
|
||||
fn output_time_adjustment_is_identity() {
|
||||
let n = TimeRemapNode;
|
||||
let t = TimeRange::new(Rational::new(10, 1), Rational::new(20, 1));
|
||||
assert_eq!(n.output_time_adjustment(INPUT_INPUT, -1, t, true), t);
|
||||
assert_eq!(n.output_time_adjustment("other_in", -1, t, true), t);
|
||||
assert_eq!(
|
||||
n.output_time_adjustment(&NodeCore::new(), INPUT_INPUT, -1, t, true),
|
||||
t
|
||||
);
|
||||
assert_eq!(
|
||||
n.output_time_adjustment(&NodeCore::new(), "other_in", -1, t, true),
|
||||
t
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
@@ -21,7 +21,29 @@
|
||||
//! `pos_in`/`rot_in`/`scale_in`/`uniform_scale_in`/`anchor_in` inputs
|
||||
//! and the `generate_matrix` helper.
|
||||
|
||||
/// The transform fragment shader: samples the input through the inverse
|
||||
/// of the node's pixel-space transform (`transform_in` carries the
|
||||
/// CPU-inverted matrix; `resolution_in` is auto-filled by the runner).
|
||||
/// The C++ path transforms vertices (`ove_mvpmat` in transform.vert);
|
||||
/// an affine transform is equivalently applied fragment-side by
|
||||
/// inverse-mapping the sample position — the fixed fullscreen vertex
|
||||
/// stage stays unchanged.
|
||||
const TRANSFORM_FRAG: &str = r#"uniform sampler2D tex_in;
|
||||
uniform mat4 transform_in;
|
||||
uniform vec2 resolution_in;
|
||||
|
||||
in vec2 ove_texcoord;
|
||||
out vec4 frag_color;
|
||||
|
||||
void main(void) {
|
||||
vec2 px = ove_texcoord * resolution_in;
|
||||
vec2 src = (transform_in * vec4(px, 0.0, 1.0)).xy;
|
||||
frag_color = texture(tex_in, src / resolution_in);
|
||||
}
|
||||
"#;
|
||||
|
||||
use crate::factory::NodeMeta;
|
||||
|
||||
use crate::jobs::ShaderJobPayload;
|
||||
use crate::node::{Category, Gizmo, NodeBehavior, NodeCore};
|
||||
|
||||
@@ -366,21 +388,37 @@ impl NodeBehavior for TransformDistortNode {
|
||||
);
|
||||
|
||||
match inputs.get(TEXTURE_INPUT) {
|
||||
Some(crate::value::NodeValue::Texture(_)) => {
|
||||
// The shader-job box (C++ `Texture::Job(globals.vparams(),
|
||||
// job)`): the behavior's type id selects the fragment
|
||||
// source, and the effect input key locates the main
|
||||
// texture inside the params row. C++ also inserts
|
||||
// `ove_mvpmat` (the auto-scaled real matrix) and sets the
|
||||
// `ove_maintex` interpolation, but the matrix needs the
|
||||
// texture's params and the sequence resolution — neither
|
||||
// available here — so it is left absent and the runner
|
||||
// fills an identity `ove_mvpmat`; the C++ identity check
|
||||
// (pass-through when the real matrix is identity) is not
|
||||
// representable either, so the job is always queued with a
|
||||
// texture (`// CPP-PARITY: transformdistortnode.cpp`
|
||||
// value(); TODO: inject the real matrix from the renderer
|
||||
// seam, where the resolution data is available).
|
||||
Some(tex @ crate::value::NodeValue::Texture(_)) => {
|
||||
// The identity transform passes the texture through (C++
|
||||
// skips the job when the real matrix is identity).
|
||||
let identity = super::mathbase::identity_matrix();
|
||||
let is_identity = generated_matrix
|
||||
.iter()
|
||||
.zip(identity)
|
||||
.all(|(a, b)| (*a - b).abs() <= 1e-9);
|
||||
if is_identity {
|
||||
table.push(crate::value::ValueType::Texture, tex.clone(), None);
|
||||
return;
|
||||
}
|
||||
|
||||
// The job carries the inverse transform in pixel space
|
||||
// (C++ inserts `ove_mvpmat` — the forward matrix for the
|
||||
// vertex stage; the fragment-side implementation samples
|
||||
// through the inverse, see TRANSFORM_FRAG). A singular
|
||||
// matrix (zero scale) passes the input through, matching
|
||||
// the C++ inverted()-fails fallback. The auto-scaled
|
||||
// variant stays unrepresentable: it needs the texture's
|
||||
// params and the sequence resolution at job-build time
|
||||
// (`// CPP-PARITY: transformdistortnode.cpp` value()).
|
||||
let Some(inverse) = super::matrix::matrix_invert_2d(generated_matrix) else {
|
||||
table.push(crate::value::ValueType::Texture, tex.clone(), None);
|
||||
return;
|
||||
};
|
||||
let mut params = inputs.clone();
|
||||
params.insert(
|
||||
"transform_in".to_string(),
|
||||
crate::value::NodeValue::Matrix(inverse),
|
||||
);
|
||||
table.push(
|
||||
crate::value::ValueType::Texture,
|
||||
crate::value::NodeValue::Texture(crate::handle::make_owned(ShaderJobPayload {
|
||||
@@ -390,7 +428,7 @@ impl NodeBehavior for TransformDistortNode {
|
||||
type_id: self.type_id().to_string(),
|
||||
shader_id: String::new(),
|
||||
effect_input: core.effect_input.clone(),
|
||||
params: inputs.clone(),
|
||||
params,
|
||||
iterative_input: TEXTURE_INPUT.to_string(),
|
||||
})),
|
||||
None,
|
||||
@@ -404,12 +442,12 @@ impl NodeBehavior for TransformDistortNode {
|
||||
}
|
||||
|
||||
/// Shader code request (C++ `get_shader_code()`): ignores the
|
||||
/// request id and returns a default (empty) `ShaderCode` — the
|
||||
/// node relies on the renderer's default vertex/fragment shaders,
|
||||
/// so this maps to `None`.
|
||||
/// request id and returns the transform fragment shader — the
|
||||
/// inverse-sampling equivalent of the C++ default vertex-shader
|
||||
/// transform (see [`TRANSFORM_FRAG`]).
|
||||
fn shader_code(&self, request: &str) -> Option<String> {
|
||||
let _ = request;
|
||||
None
|
||||
Some(TRANSFORM_FRAG.to_string())
|
||||
}
|
||||
|
||||
/// Gizmo transform/positions (C++ `update_gizmo_positions()` and
|
||||
@@ -715,11 +753,31 @@ mod tests {
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn value_pushes_matrix_and_job_with_texture() {
|
||||
fn value_identity_transform_passes_texture_through() {
|
||||
// Default inputs (position 0, rotation 0, scale 1) generate the
|
||||
// identity matrix: the texture passes through without a job (C++
|
||||
// skips the job when the real matrix is identity).
|
||||
let (core, behavior) = create();
|
||||
let inputs = crate::value::NodeValueRow::from([(TEXTURE_INPUT.to_string(), tex())]);
|
||||
let mut table = NodeValueTable::default();
|
||||
behavior.value(&core, &inputs, Rational::new(0, 1), &mut table);
|
||||
assert!(table.get(ValueType::Matrix).is_some());
|
||||
assert_eq!(table.get(ValueType::Texture), Some(&tex()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn value_pushes_matrix_and_job_with_texture() {
|
||||
let (mut core, behavior) = create();
|
||||
// A real transform (position +50 in x) generates a non-identity
|
||||
// matrix, so the shader job is queued.
|
||||
core.set_standard_value(
|
||||
super::super::matrix::POSITION_INPUT,
|
||||
-1,
|
||||
NodeValue::Vec2([50.0, 0.0]),
|
||||
);
|
||||
let inputs = crate::value::NodeValueRow::from([(TEXTURE_INPUT.to_string(), tex())]);
|
||||
let mut table = NodeValueTable::default();
|
||||
behavior.value(&core, &inputs, Rational::new(0, 1), &mut table);
|
||||
// Matrix output always pushed; texture job queued for the seam.
|
||||
assert!(table.get(ValueType::Matrix).is_some());
|
||||
let handle = match table.get(ValueType::Texture).unwrap() {
|
||||
@@ -734,6 +792,12 @@ mod tests {
|
||||
assert_eq!(payload.effect_input, TEXTURE_INPUT);
|
||||
assert_eq!(payload.time, Rational::new(0, 1));
|
||||
assert!(payload.params.contains_key(TEXTURE_INPUT));
|
||||
// The job carries the inverse of the +50px translation
|
||||
// (fragment-side inverse sampling).
|
||||
match payload.params.get("transform_in") {
|
||||
Some(NodeValue::Matrix(m)) => assert_eq!(m[3], -50.0),
|
||||
other => panic!("transform_in must carry the inverse matrix: {other:?}"),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -771,9 +835,11 @@ mod tests {
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn shader_code_returns_none() {
|
||||
fn shader_code_returns_inverse_sampling_frag() {
|
||||
let n = empty_node();
|
||||
assert!(n.shader_code("anything").is_none());
|
||||
let frag = n.shader_code("anything").expect("real transform shader");
|
||||
assert!(frag.contains("transform_in"));
|
||||
assert!(frag.contains("resolution_in"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
@@ -221,7 +221,7 @@ fn adjusted_time(
|
||||
) -> Rational {
|
||||
entry
|
||||
.behavior
|
||||
.input_time_adjustment(input, element, TimeRange::new(time, time), true)
|
||||
.input_time_adjustment(&entry.core, input, element, TimeRange::new(time, time), true)
|
||||
.in_()
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user