app: project-explorer new-sequence button, 4K presets, keying node fix, color-picker canvases

- Project explorer header gains a 新建序列 button (opens the existing
  new-sequence dialog, seeded like the menu action).
- Sequence presets gain 4K UHD (3840x2160@25) and 4K DCI (4096x2160@24);
  the sequence-properties dialog re-selects them on reopen. Format fields
  are also seedable from a probed footage format (the drop flow).
- Chroma Key (and Color Difference Key) value() now box a ShaderJobPayload
  like Despill: the old OCIO-processor gate pushed nothing (the processor
  is never populated without the render bridge), so the traverser handed
  the clip NodeValue::None and the rendered frame lost the clip. The
  renderer resolves the OCIO stub at compile time from OCIO_SHADER_STUBS.
  End-to-end graph test: green key on a green frame keys out, red key
  keeps it.
- The OFX color picker's SV palette / hue bar / preview / swatch canvases
  get size_full(): the bare canvases collapsed to zero height in the
  block layout, so the palette painted nothing (the reported 色板没显示).
  Regression test clicks the palette center and expects mid s/v.
This commit is contained in:
2026-08-30 21:58:26 +08:00
parent 71bc748d98
commit 5937e557a7
20 changed files with 988 additions and 165 deletions
+73 -109
View File
@@ -19,6 +19,7 @@
use crate::factory::NodeMeta;
use crate::node::{Category, NodeBehavior, NodeCore};
use crate::nodes::jobs::ShaderJobPayload;
/// Key color input id (C++ `k_color_input`). Type: color; default
/// `Color(0.0, 1.0, 0.0, 1.0)` (opaque green).
@@ -64,14 +65,12 @@ pub const HIGHLIGHTS_INPUT: &str = "highlights_in";
/// The C++ class derives from `OCIOBaseNode`, which owns the `tex_in`
/// texture input (C++ `OCIOBaseNode::k_texture_input = "tex_in"`, the
/// effect input), the color manager pointer, and the OCIO color
/// processor handle; that state is held here via the shared
/// `crate::nodes::ociobase` helper. The class has no other own members (the
/// private `generate_processor()` is a method, not state).
pub struct ChromaKeyNode {
/// OCIO base state (C++ base class `OCIOBaseNode`: `manager_` and
/// `processor_`).
base: crate::nodes::ociobase::OcioBase,
}
/// processor handle. In the Rust model the OCIO shader stub is
/// generated by the renderer at compile time (the `%1` marker is a
/// type-id lookup in oakrender's OCIO shader-stub table), so the node
/// needs no processor state: it behaves like the other keying nodes
/// and pushes a plain shader job payload from `value()`.
pub struct ChromaKeyNode;
/// Fragment shader (C++ loads the `:/shaders/chromakey.frag` resource
/// in `get_shader_code`). Text copied verbatim from
@@ -205,22 +204,6 @@ impl ChromaKeyNode {
fn shader_frag() -> &'static str {
SHADER_FRAG
}
/// (Re)build the OCIO color processor (C++ `generate_processor()`):
/// converts to the `cie_xyz_d65_interchange` output color space via
/// `oakrender_color_processor_create_transform` and stores the result
/// with [`OcioBase::set_processor`] when creation succeeds.
fn generate_processor(&mut self, _core: &mut NodeCore) {
// The C++ wraps the color manager, builds a transform to the
// "cie_xyz_d65_interchange" output color space and creates the
// processor through `oakrender_color_processor_create_transform`,
// storing it with OcioBase::set_processor when `processor.ctx` is
// non-null. Without a manager (the Rust model reaches the manager
// through the oakrender bridge, absent here) the C++ guard
// `if (manager())` fails, so this is a no-op and the processor
// stays empty — `value()` then pushes nothing.
// `// CPP-PARITY: chromakey.cpp` generate_processor.
}
}
impl NodeBehavior for ChromaKeyNode {
@@ -267,30 +250,33 @@ impl NodeBehavior for ChromaKeyNode {
}
}
/// Input value changed (C++ `InputValueChangedEvent`): the lower
/// tolerance branch that would update the upper tolerance's `min`
/// property is disabled in C++ (FIXME); unconditionally
/// regenerates the OCIO color processor
/// (`generate_processor()`).
fn input_value_changed(&mut self, core: &mut NodeCore, input: &str, element: i32) {
/// Input value changed (C++ `InputValueChangedEvent`): the C++ runs
/// the lower-tolerance branch (commented out upstream, FIXME) and
/// regenerates the OCIO processor. This node has no processor state
/// left (the renderer resolves the OCIO stub at compile time), so
/// nothing to do here.
fn input_value_changed(&mut self, _core: &mut NodeCore, input: &str, element: i32) {
let _ = (input, element);
// The C++ lower-tolerance branch that mirrors the lower tolerance
// into the upper tolerance's `min` property is commented out
// (FIXME: breaks when the lower tolerance is keyframed/connected),
// so only the processor regeneration remains.
self.generate_processor(core);
}
/// Evaluate outputs (C++ `value()`): no texture on `tex_in` ->
/// push nothing; texture present and a valid OCIO processor ->
/// push a `ColorTransformJob` wired with the processor, the input
/// texture, this node as the custom-shader provider, and the
/// function name `SceneLinearToCIEXYZ_d65`.
/// push nothing; texture present -> push a `ShaderJob` over the
/// whole input row.
///
/// The C++ class also overrides `config_changed()` (pure virtual
/// on `OCIOBaseNode`) to regenerate the processor when the OCIO
/// config changes; `NodeBehavior` has no equivalent hook — that
/// wiring belongs to the facade/event layer.
/// The C++ gates the job on a valid OCIO processor (a
/// `ColorTransformJob` carrying the processor handle — the
/// `%1` marker is filled with the processor's generated shader
/// code). The Rust model has no processor bridge; the renderer
/// resolves the OCIO stub itself: `oak_render::eval` looks the
/// node's type id up in its OCIO shader-stub table and generates
/// `SceneLinearToCIEXYZ_d65` at compile time, so the node pushes
/// the plain job payload exactly like the other keying nodes
/// (despill / color-difference key). The old gate — a null handle
/// when `processor().is_some()` and *nothing* otherwise — left the
/// node's output table empty (the processor is never populated
/// without the bridge), so the traverser handed the downstream
/// clip `NodeValue::None` and the rendered frame lost the whole
/// clip.
fn value(
&self,
core: &NodeCore,
@@ -298,29 +284,24 @@ impl NodeBehavior for ChromaKeyNode {
time: oak_core::Rational,
table: &mut crate::value::NodeValueTable,
) {
let _ = (core, time);
match inputs.get(crate::nodes::ociobase::TEXTURE_INPUT) {
Some(crate::value::NodeValue::Texture(_)) => {
if self.base.processor().is_some() {
// `// CPP-PARITY: chromakey.cpp` `value()` — the C++
// builds a ColorTransformJob with the processor, the
// input texture, this node as the custom-shader
// provider and the function name
// `SceneLinearToCIEXYZ_d65`. The Rust model has no
// color-transform job payload: the renderer seam
// resolves the deferred job from this null handle.
table.push(
crate::value::ValueType::Texture,
crate::value::NodeValue::Texture(crate::handle::CHandle::null()),
None,
);
}
// Texture present but no processor: the C++ pushes
// nothing (unlike the base class, which would pass the
// texture through).
}
_ => {}
Some(crate::value::NodeValue::Texture(_)) => {}
_ => return,
}
table.push(
crate::value::ValueType::Texture,
crate::value::NodeValue::Texture(crate::handle::make_owned(ShaderJobPayload {
node_id: crate::id::NodeId::INVALID,
time,
iterations: 1,
type_id: self.type_id().to_string(),
shader_id: String::new(),
effect_input: core.effect_input.clone(),
params: inputs.clone(),
iterative_input: String::new(),
})),
None,
);
}
/// Shader code request (C++ `get_shader_code()`): reads the
@@ -345,12 +326,7 @@ impl NodeBehavior for ChromaKeyNode {
/// Deep copy (C++ `copy()` via `NODE_DEFAULT_FUNCTIONS`).
fn duplicate(&self, _core: &NodeCore) -> Option<Box<dyn NodeBehavior>> {
// The C++ copy constructor copies the embedded OCIO base state;
// a fresh base with no processor is the safe Rust port (the
// processor is never populated without the render bridge).
Some(Box::new(ChromaKeyNode {
base: crate::nodes::ociobase::OcioBase::new(),
}))
Some(Box::new(ChromaKeyNode))
}
}
@@ -449,12 +425,7 @@ pub fn create() -> (NodeCore, Box<dyn NodeBehavior>) {
crate::value::NodeValue::Boolean(false),
));
(
core,
Box::new(ChromaKeyNode {
base: crate::nodes::ociobase::OcioBase::new(),
}),
)
(core, Box::new(ChromaKeyNode))
}
/// Register this node type (C++ factory entry for
@@ -476,9 +447,7 @@ mod tests {
#[test]
fn input_names() {
let n = ChromaKeyNode {
base: crate::nodes::ociobase::OcioBase::new(),
};
let n = ChromaKeyNode;
assert_eq!(n.input_name(crate::nodes::ociobase::TEXTURE_INPUT), "Input");
assert_eq!(n.input_name(GARBAGE_MATTE_INPUT), "Garbage Matte");
assert_eq!(n.input_name(CORE_MATTE_INPUT), "Core Matte");
@@ -542,9 +511,7 @@ mod tests {
#[test]
fn shader_code_replaces_stub_marker() {
let n = ChromaKeyNode {
base: crate::nodes::ociobase::OcioBase::new(),
};
let n = ChromaKeyNode;
let stub = "float SceneLinearToCIEXYZ_d65(vec4 c){ return 0.0; }";
let code = n.shader_code(stub).unwrap();
assert!(!code.contains("%1"));
@@ -553,9 +520,7 @@ mod tests {
#[test]
fn legacy_input_ids_remap_misspellings() {
let n = ChromaKeyNode {
base: crate::nodes::ociobase::OcioBase::new(),
};
let n = ChromaKeyNode;
assert_eq!(
n.map_legacy_input_id("upper_tolerence_in"),
UPPER_TOLERANCE_INPUT
@@ -584,34 +549,33 @@ mod tests {
}
#[test]
fn value_texture_without_processor_pushes_nothing() {
// Unlike the OCIO base, chroma key has no pass-through branch:
// without a processor the C++ pushes nothing.
fn value_texture_pushes_shader_job_payload() {
let (core, behavior) = create();
let inputs = crate::value::NodeValueRow::from([(
crate::nodes::ociobase::TEXTURE_INPUT.to_string(),
NodeValue::Texture(crate::handle::CHandle::null()),
)]);
// The payload carries the whole input row (the renderer packs the
// uniforms from it), with the full node identity + effect input.
let inputs = crate::value::NodeValueRow::from([
(
crate::nodes::ociobase::TEXTURE_INPUT.to_string(),
NodeValue::Texture(crate::handle::CHandle::null()),
),
(COLOR_INPUT.to_string(), NodeValue::Color([1.0, 0.0, 0.0, 1.0])),
]);
let mut table = NodeValueTable::default();
behavior.value(&core, &inputs, Rational::new(0, 1), &mut table);
assert!(table.is_empty());
}
#[test]
fn value_texture_with_processor_pushes_deferred_job() {
let core = NodeCore::new();
let mut node = ChromaKeyNode {
base: crate::nodes::ociobase::OcioBase::new(),
let NodeValue::Texture(handle) = table.get(ValueType::Texture).unwrap() else {
panic!("expected a texture-typed value");
};
node.base
.set_processor(Some(crate::handle::CHandle::null()));
let inputs = crate::value::NodeValueRow::from([(
crate::nodes::ociobase::TEXTURE_INPUT.to_string(),
NodeValue::Texture(crate::handle::CHandle::null()),
)]);
let mut table = NodeValueTable::default();
node.value(&core, &inputs, Rational::new(0, 1), &mut table);
assert!(table.get(ValueType::Texture).is_some());
let payload =
unsafe { crate::handle::get_checked::<ShaderJobPayload>(handle) }
.expect("payload boxed behind the handle");
assert_eq!(payload.type_id, "org.olivevideoeditor.Olive.chromakey");
assert_eq!(payload.shader_id, "");
assert_eq!(payload.iterations, 1);
assert_eq!(payload.effect_input, crate::nodes::ociobase::TEXTURE_INPUT);
// The whole input row rides along, so the renderer can pack the
// Key Color + tolerances uniforms from it.
assert_eq!(payload.params.get(COLOR_INPUT), Some(&NodeValue::Color([1.0, 0.0, 0.0, 1.0])));
assert!(payload.params.contains_key(crate::nodes::ociobase::TEXTURE_INPUT));
}
#[test]
+29 -11
View File
@@ -20,6 +20,7 @@
use crate::factory::NodeMeta;
use crate::node::{Category, NodeBehavior, NodeCore};
use crate::nodes::jobs::ShaderJobPayload;
/// Texture input id (C++ `k_texture_input`). Type: texture; flags:
/// not-keyframable; this is the node's effect input.
@@ -186,6 +187,13 @@ impl NodeBehavior for ColorDifferenceKeyNode {
/// Evaluate outputs (C++ `value()`): no texture on `tex_in` ->
/// push nothing; texture present -> push a `ShaderJob` with the
/// whole input row inserted.
///
/// The job is boxed here as a [`ShaderJobPayload`] that the
/// renderer's resolve hook executes and replaces with the result
/// texture (exactly like despill). The previous null-handle
/// "deferred job" marker was never resolved by the Rust renderer,
/// so the node's output table handed the downstream clip a null
/// texture and the rendered frame lost the whole clip.
fn value(
&self,
core: &NodeCore,
@@ -193,21 +201,22 @@ impl NodeBehavior for ColorDifferenceKeyNode {
time: oak_core::Rational,
table: &mut crate::value::NodeValueTable,
) {
let _ = (core, time);
match inputs.get(TEXTURE_INPUT) {
Some(crate::value::NodeValue::Texture(_)) => {}
_ => return,
}
// `// CPP-PARITY: colordifferencekey.cpp` `value()` — the C++
// builds a ShaderJob over the whole input row and pushes
// `tex->to_job(job)`. The Rust model has no shader-job payload:
// the renderer seam resolves the deferred job (and the
// `garbage_in_enabled`/`core_in_enabled` uniforms derived from
// input presence) from this null handle.
table.push(
crate::value::ValueType::Texture,
crate::value::NodeValue::Texture(crate::handle::CHandle::null()),
crate::value::NodeValue::Texture(crate::handle::make_owned(ShaderJobPayload {
node_id: crate::id::NodeId::INVALID,
time,
iterations: 1,
type_id: self.type_id().to_string(),
shader_id: String::new(),
effect_input: core.effect_input.clone(),
params: inputs.clone(),
iterative_input: String::new(),
})),
None,
);
}
@@ -368,7 +377,7 @@ mod tests {
}
#[test]
fn value_with_texture_pushes_deferred_shader_job() {
fn value_with_texture_pushes_shader_job_payload() {
let (core, behavior) = create();
let inputs = crate::value::NodeValueRow::from([(
TEXTURE_INPUT.to_string(),
@@ -376,7 +385,16 @@ 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 NodeValue::Texture(handle) = table.get(ValueType::Texture).unwrap() else {
panic!("expected a texture-typed value");
};
let payload =
unsafe { crate::handle::get_checked::<ShaderJobPayload>(handle) }
.expect("payload boxed behind the handle");
assert_eq!(payload.type_id, "org.olivevideoeditor.Olive.colordifferencekey");
assert_eq!(payload.iterations, 1);
assert_eq!(payload.effect_input, TEXTURE_INPUT);
assert!(payload.params.contains_key(TEXTURE_INPUT));
}
#[test]