Files
oak-editor/crates/oak-node/src/nodes/pan.rs
T
Mike-Solar ee7ea18d94 clippy: clear the workspace errors and apply the machine fixes
- Mark the raw-pointer interop entry points unsafe with # Safety docs
  (oak-core upload/download/frame-from-pixels, oak-audio convert) and
  satisfy the existing callers (tests).
- mut_from_ref: allow with the ABI contract documented (the handle
  get_mut helpers in oak-timeline/oak-render/oak-task take the shared
  reference the C ABI passes; exclusivity is the caller's unsafe
  contract).
- Fix the eq_op in the white-balance normalization (green / green).
- Apply cargo clippy --fix across the workspace (redundant closures and
  field names, field reassignment, items after test modules, ...).
- Revert the replace_box fix in image_effect's clip_define: a
  redefinition must allocate a new box, otherwise the old clip handle
  stays valid and the HS-map replace contract (clip != clip2) breaks.
- 283 warnings remain; they are all non-machine-applicable
  (chunks_exact -> as_chunks needs a manual iter_mut, too_many_arguments,
  complex types, missing Safety docs, ...) and are tracked as the
  follow-up.
2026-09-15 19:29:32 +08:00

352 lines
11 KiB
Rust

// 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/>.
//! Pan audio effect (C++ `src/node/src/audio/pan/pan.{h,cpp}`,
//! `olive::PanNode`).
use crate::factory::NodeMeta;
use crate::node::{Category, NodeBehavior, NodeCore};
use crate::value::{NodeValue, ValueType};
/// Samples input id (C++ `k_samples_input`). Type: samples; flags:
/// not-keyframable; this is the node's effect input
/// (`set_effect_input(k_samples_input)`).
pub const SAMPLES_INPUT: &str = "samples_in";
/// Panning input id (C++ `k_panning_input`). Type: float; default `0.0`;
/// properties: `min = -1.0`, `max = 1.0`, `view = percentage`.
pub const PANNING_INPUT: &str = "panning_in";
/// Pan effect node. Attenuates the left or right channel of a stereo
/// sample buffer according to a -1..1 panning value.
///
/// The C++ class keeps `NodeInput *samples_input_` /
/// `NodeInput *panning_input_` back-pointers to its own inputs; in Rust
/// inputs live in [`NodeCore`], so there are no own fields.
pub struct PanNode;
impl NodeBehavior for PanNode {
/// Human-readable name (C++ `name()`).
fn name(&self) -> &str {
"Pan"
}
/// Stable type id (C++ `id()`).
fn type_id(&self) -> &str {
"org.olivevideoeditor.Olive.pan"
}
/// Categories (C++ `category()`).
fn categories(&self) -> &[Category] {
&[Category::Filter]
}
/// Description (C++ `description()`).
fn description(&self) -> &str {
"Adjust the stereo panning of an audio source."
}
/// Localized input names (C++ `retranslate()`): `samples_in` ->
/// "Samples", `panning_in` -> "Pan".
fn input_name<'a>(&self, id: &'a str) -> &'a str {
match id {
SAMPLES_INPUT => "Samples",
PANNING_INPUT => "Pan",
_ => id,
}
}
/// Evaluate outputs (C++ `value()`): unallocated samples input ->
/// push nothing; non-stereo (channel count != 2) samples -> push the
/// input through unchanged; stereo with a static panning input ->
/// apply the attenuation immediately (pan > 0 scales channel 0 by
/// `1.0 - pan`, pan < 0 scales channel 1 by `1.0 + pan`, pan == 0
/// leaves the buffer untouched) and push the transformed samples;
/// stereo with a non-static panning input -> build a `SampleJob`
/// over `samples_in` + `panning_in` and push it as a samples value.
///
/// The Rust model has no `SampleJob` payload: the dynamic case
/// pushes the input samples through unchanged and the audio
/// renderer applies the per-index pan via [`Self::process_samples`]
/// (`// CPP-PARITY: pan.cpp` `value()`).
fn value(
&self,
core: &NodeCore,
inputs: &crate::value::NodeValueRow,
time: oak_core::Rational,
table: &mut crate::value::NodeValueTable,
) {
let samples = match inputs.get(SAMPLES_INPUT) {
Some(NodeValue::Samples(b)) if b.is_allocated() => b.clone(),
_ => return,
};
// This node is only compatible with stereo audio.
if samples.channels != 2 {
table.push(ValueType::Samples, NodeValue::Samples(samples), None);
return;
}
if core.is_input_static(inputs, PANNING_INPUT, -1) {
let pan_volume = core.value_at_time(PANNING_INPUT, -1, time).to_double();
if pan_volume != 0.0 {
let mut transformed = samples;
if pan_volume > 0.0 {
transformed.transform_volume_for_channel(0, 1.0 - pan_volume);
} else {
transformed.transform_volume_for_channel(1, 1.0 + pan_volume);
}
table.push(ValueType::Samples, NodeValue::Samples(transformed), None);
} else {
table.push(ValueType::Samples, NodeValue::Samples(samples), None);
}
} else {
// Dynamic panning input: deferred sample job.
table.push(ValueType::Samples, NodeValue::Samples(samples), None);
}
}
/// Process a span of samples (C++ `process_samples()`): copies every
/// input channel to the output at the current index, then applies the
/// pan — pan > 0 attenuates output channel 0 by `1.0 - pan`, pan < 0
/// attenuates output channel 1 by `1.0 - abs(pan)`, pan == 0 is a
/// straight copy. The C++ signature receives the input buffer and a
/// sample index; the Rust trait instead hands over a time `range` and
/// the destination buffer, so the whole output span is filled here.
fn process_samples(
&self,
core: &NodeCore,
inputs: &crate::value::NodeValueRow,
range: oak_core::TimeRange,
output: &mut crate::value::SampleBuffer,
) {
let input = match inputs.get(SAMPLES_INPUT) {
Some(NodeValue::Samples(b)) => b,
_ => return,
};
let pan_val = match inputs.get(PANNING_INPUT) {
Some(v) => v.to_double(),
None => core
.value_at_time(PANNING_INPUT, -1, range.in_())
.to_double(),
};
for c in 0..output.channels {
for i in 0..output.sample_count {
let v = input.sample_value(c, i);
output.set_sample_value(c, i, v);
}
}
if pan_val > 0.0 {
for i in 0..output.sample_count {
let v = output.sample_value(0, i);
output.set_sample_value(0, i, v * (1.0 - pan_val));
}
} else if pan_val < 0.0 {
for i in 0..output.sample_count {
let v = output.sample_value(1, i);
output.set_sample_value(1, i, v * (1.0 - pan_val.abs()));
}
}
}
/// Deep copy (C++ `copy()`).
fn duplicate(&self, _core: &NodeCore) -> Option<Box<dyn NodeBehavior>> {
Some(Box::new(PanNode))
}
}
/// Constructor (C++ `PanNode::PanNode()`): adds `samples_in` (samples,
/// not-keyframable) and `panning_in` (float, default 0.0, min/max/view
/// properties documented on the constant), sets the audio-effect flag
/// and makes `samples_in` the effect input.
pub fn create() -> (NodeCore, Box<dyn NodeBehavior>) {
let mut core = NodeCore::new();
let mut samples = crate::input::Input::new(SAMPLES_INPUT, ValueType::Samples, NodeValue::None);
samples.flags |= crate::input::flags::NOT_KEYFRAMABLE;
core.add_input(samples);
let mut panning =
crate::input::Input::new(PANNING_INPUT, ValueType::Float, NodeValue::Float(0.0));
panning.properties = vec![
("min".to_string(), NodeValue::Float(-1.0)),
("max".to_string(), NodeValue::Float(1.0)),
("view".to_string(), NodeValue::Text("percentage".into())),
];
core.add_input(panning);
core.flags |= crate::node::flags::AUDIO_EFFECT;
core.effect_input = SAMPLES_INPUT.to_string();
(core, Box::new(PanNode))
}
/// Register this node type (C++ `k_audio_panning` in
/// `factory.cpp::create_from_factory_index`).
pub fn register(meta: &mut Vec<NodeMeta>) {
meta.push(NodeMeta {
type_id: "org.olivevideoeditor.Olive.pan",
name: "Pan",
categories: &[Category::Filter],
create,
});
}
#[cfg(test)]
mod tests {
use super::*;
use crate::value::NodeValueTable;
use oak_core::{Rational, SampleFormat, TimeRange};
fn planar(channels: usize, count: usize, values: &[f64]) -> crate::value::SampleBuffer {
let mut buf = crate::value::SampleBuffer {
format: SampleFormat::F32Planar,
channels,
sample_count: count,
data: vec![0u8; channels * count * 4],
};
for c in 0..channels {
for i in 0..count {
buf.set_sample_value(c, i, values[c * count + i]);
}
}
buf
}
#[test]
fn input_names() {
let n = PanNode;
assert_eq!(n.input_name(SAMPLES_INPUT), "Samples");
assert_eq!(n.input_name(PANNING_INPUT), "Pan");
}
#[test]
fn create_wires_inputs_and_flags() {
let (core, behavior) = create();
assert_eq!(behavior.type_id(), "org.olivevideoeditor.Olive.pan");
assert_eq!(
core.get_input(PANNING_INPUT).unwrap().default,
NodeValue::Float(0.0)
);
assert_eq!(core.effect_input, SAMPLES_INPUT);
assert_ne!(core.flags & crate::node::flags::AUDIO_EFFECT, 0);
}
#[test]
fn value_mono_passes_through() {
let (mut core, behavior) = create();
core.set_standard_value(PANNING_INPUT, -1, NodeValue::Float(1.0));
let buf = planar(1, 2, &[1.0, 2.0]);
let inputs = std::collections::BTreeMap::from([(
SAMPLES_INPUT.to_string(),
NodeValue::Samples(buf.clone()),
)]);
let mut table = NodeValueTable::default();
behavior.value(&core, &inputs, Rational::new(0, 1), &mut table);
let out = match table.get(ValueType::Samples).unwrap() {
NodeValue::Samples(s) => s,
_ => panic!("samples"),
};
assert_eq!(out.sample_value(0, 0), 1.0, "non-stereo untouched");
}
#[test]
fn value_static_pan_left_attenuates_right() {
let (mut core, behavior) = create();
core.set_standard_value(PANNING_INPUT, -1, NodeValue::Float(-1.0));
let buf = planar(2, 1, &[1.0, 1.0]);
let inputs = std::collections::BTreeMap::from([(
SAMPLES_INPUT.to_string(),
NodeValue::Samples(buf.clone()),
)]);
let mut table = NodeValueTable::default();
behavior.value(&core, &inputs, Rational::new(0, 1), &mut table);
let out = match table.get(ValueType::Samples).unwrap() {
NodeValue::Samples(s) => s,
_ => panic!("samples"),
};
assert_eq!(out.sample_value(0, 0), 1.0, "left stays");
assert_eq!(out.sample_value(1, 0), 0.0, "right = 1 + (-1)");
}
#[test]
fn value_static_pan_right_attenuates_left() {
let (mut core, behavior) = create();
core.set_standard_value(PANNING_INPUT, -1, NodeValue::Float(0.5));
let buf = planar(2, 1, &[1.0, 1.0]);
let inputs = std::collections::BTreeMap::from([(
SAMPLES_INPUT.to_string(),
NodeValue::Samples(buf.clone()),
)]);
let mut table = NodeValueTable::default();
behavior.value(&core, &inputs, Rational::new(0, 1), &mut table);
let out = match table.get(ValueType::Samples).unwrap() {
NodeValue::Samples(s) => s,
_ => panic!("samples"),
};
assert_eq!(out.sample_value(0, 0), 0.5, "left = 1 - 0.5");
assert_eq!(out.sample_value(1, 0), 1.0, "right stays");
}
#[test]
fn value_static_pan_center_untouched() {
let (mut core, behavior) = create();
core.set_standard_value(PANNING_INPUT, -1, NodeValue::Float(0.0));
let buf = planar(2, 1, &[1.0, 1.0]);
let inputs = std::collections::BTreeMap::from([(
SAMPLES_INPUT.to_string(),
NodeValue::Samples(buf.clone()),
)]);
let mut table = NodeValueTable::default();
behavior.value(&core, &inputs, Rational::new(0, 1), &mut table);
let out = match table.get(ValueType::Samples).unwrap() {
NodeValue::Samples(s) => s,
_ => panic!("samples"),
};
assert_eq!(out.sample_value(0, 0), 1.0);
assert_eq!(out.sample_value(1, 0), 1.0);
}
#[test]
fn process_samples_pan_right() {
let (mut core, behavior) = create();
core.set_standard_value(PANNING_INPUT, -1, NodeValue::Float(0.25));
let buf = planar(2, 1, &[1.0, 1.0]);
let inputs = std::collections::BTreeMap::from([
(SAMPLES_INPUT.to_string(), NodeValue::Samples(buf)),
(PANNING_INPUT.to_string(), NodeValue::Float(0.25)),
]);
let mut out = planar(2, 1, &[0.0, 0.0]);
behavior.process_samples(
&core,
&inputs,
TimeRange::new(Rational::new(0, 1), Rational::new(1, 1)),
&mut out,
);
assert_eq!(out.sample_value(0, 0), 0.75);
assert_eq!(out.sample_value(1, 0), 1.0);
}
#[test]
fn duplicate_clones() {
let (core, behavior) = create();
let dup = behavior.duplicate(&core).unwrap();
assert_eq!(dup.name(), "Pan");
}
}