// 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 . //! Time offset node (C++ `src/node/src/time/timeoffset/timeoffsetnode.{h,cpp}`, //! `olive::TimeOffsetNode`). use crate::factory::NodeMeta; use crate::node::{Category, NodeBehavior, NodeCore}; use crate::value::{NodeValue, NodeValueRow, NodeValueTable}; use oakcore_rs::{Rational, TimeRange}; /// Time offset input id (C++ `k_time_input`). Type: rational; default /// `Rational(0)`; flags: not-connectable; properties: `view = time slider`, /// `viewlock = true`. pub const TIME_INPUT: &str = "time_in"; /// Effect input id (C++ `k_input_input`). Type: none (pass-through of any /// connected type); flags: not-keyframable. pub const INPUT_INPUT: &str = "input_in"; /// Time offset node. Shifts the time seen by the connected input by a /// constant amount. The C++ class has no own data members (only the private /// `get_remapped_time()`/`get_remapped_output_time()` helpers, which become /// the behavior of `input_time_adjustment`/`output_time_adjustment` below), /// so this is a unit-like struct. pub struct TimeOffsetNode; impl TimeOffsetNode { /// C++ `get_remapped_time()`: the `time_in` input's value at /// `input_time` added to `input_time` — `input + time_in`. `time_in` /// is not-connectable, so no connected edge is ever consulted. pub fn get_remapped_time(core: &NodeCore, input_time: Rational) -> Rational { input_time + Self::time_offset(core, input_time) } /// C++ `get_remapped_output_time()`: the inverse of /// [`Self::get_remapped_time`] — `input - time_in`. pub fn get_remapped_output_time(core: &NodeCore, input_time: Rational) -> Rational { input_time - Self::time_offset(core, input_time) } /// The `time_in` value evaluated at `input_time` (C++ `get_value_at_time /// (k_time_input, input).value()`). fn time_offset(core: &NodeCore, input_time: Rational) -> Rational { match core.value_at_time(TIME_INPUT, -1, input_time) { NodeValue::Rational(r) => r, v => Rational::from_double(v.to_double()), } } /// The full C++ `input_time_adjustment()` with an explicit core. For /// `input_in`, both endpoints of the range are shifted forward by the /// `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. pub fn input_time_adjustment_with( core: &NodeCore, input: &str, element: i32, time: TimeRange, traverse: bool, ) -> TimeRange { let _ = (element, traverse); if input == INPUT_INPUT { TimeRange::new( Self::get_remapped_time(core, time.in_()), Self::get_remapped_time(core, time.out()), ) } else { time } } /// The full C++ `output_time_adjustment()` with an explicit core: the /// exact inverse of [`Self::input_time_adjustment_with`] — for /// `input_in`, both endpoints are shifted back by the `time_in` value. pub fn output_time_adjustment_with( core: &NodeCore, input: &str, element: i32, time: TimeRange, traverse: bool, ) -> TimeRange { let _ = (element, traverse); if input == INPUT_INPUT { TimeRange::new( Self::get_remapped_output_time(core, time.in_()), Self::get_remapped_output_time(core, time.out()), ) } else { time } } } impl NodeBehavior for TimeOffsetNode { /// Human-readable name (C++ `name()`). fn name(&self) -> &str { "Time Offset" } /// Stable type id (C++ `id()`). fn type_id(&self) -> &str { "org.olivevideoeditor.Olive.timeoffset" } /// Categories (C++ `category()` returns `{ k_category_time }`; the Rust /// `Category` enum has no `Time` variant yet, so this is empty until one /// is added). fn categories(&self) -> &[Category] { &[] } /// Description (C++ `description()`). fn description(&self) -> &str { "Offset time passing through the graph." } /// Localized input names (C++ `retranslate()`): `time_in` -> "Time", /// `input_in` -> "Input". fn input_name<'a>(&self, id: &'a str) -> &'a str { match id { TIME_INPUT => "Time", INPUT_INPUT => "Input", _ => id, } } /// Input-side time remap (C++ `input_time_adjustment()`): for /// `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 /// (`// CPP-PARITY: timeoffsetnode.cpp` `input_time_adjustment`). fn input_time_adjustment( &self, input: &str, element: i32, time: TimeRange, traverse: bool, ) -> TimeRange { let _ = (input, element, traverse); time } /// 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`] /// (`// CPP-PARITY: timeoffsetnode.cpp` `output_time_adjustment`). fn output_time_adjustment( &self, input: &str, element: i32, time: TimeRange, traverse: bool, ) -> TimeRange { let _ = (input, element, traverse); time } /// Evaluate outputs (C++ `value()`): pushes the value arriving at /// `input_in` through unchanged (the actual time shift happens via the /// time-adjustment overrides above). fn value( &self, core: &NodeCore, inputs: &NodeValueRow, time: Rational, table: &mut NodeValueTable, ) { let _ = (core, time); // `table->push(value.at(k_input_input))` — the value passes through // unchanged, whatever its type (texture values included). if let Some(v) = inputs.get(INPUT_INPUT) { table.push(v.value_type(), v.clone(), None); } } /// Deep copy (C++ `copy()`). fn duplicate(&self, _core: &NodeCore) -> Option> { Some(Box::new(TimeOffsetNode)) } } /// Constructor (C++ `TimeOffsetNode::TimeOffsetNode()`): adds `time_in` /// (rational, default 0, not-connectable, time-slider view with viewlock) /// and `input_in` (type-none pass-through, not-keyframable). pub fn create() -> (NodeCore, Box) { let mut core = NodeCore::new(); let mut time_input = crate::input::Input::new( TIME_INPUT, crate::value::ValueType::Rational, NodeValue::Rational(Rational::new(0, 1)), ); time_input.flags |= crate::input::flags::NOT_CONNECTABLE; time_input.properties = vec![ ("view".to_string(), NodeValue::Text("time".to_string())), ("viewlock".to_string(), NodeValue::Boolean(true)), ]; core.add_input(time_input); let mut input_input = crate::input::Input::new(INPUT_INPUT, crate::value::ValueType::None, NodeValue::None); input_input.flags |= crate::input::flags::NOT_KEYFRAMABLE; core.add_input(input_input); (core, Box::new(TimeOffsetNode)) } /// Register this node type (C++ factory listing for /// `org.olivevideoeditor.Olive.timeoffset`; see the note on /// [`NodeBehavior::categories`] about the missing `Time` category). pub fn register(meta: &mut Vec) { meta.push(NodeMeta { type_id: "org.olivevideoeditor.Olive.timeoffset", name: "Time Offset", categories: &[], create, }); } #[cfg(test)] mod tests { use super::*; use crate::node::NodeBehavior; use crate::value::{NodeValueTable, ValueType}; use oakcore_rs::Rational; #[test] fn input_names() { let n = TimeOffsetNode; assert_eq!(n.input_name(TIME_INPUT), "Time"); assert_eq!(n.input_name(INPUT_INPUT), "Input"); assert_eq!(n.input_name("other_in"), "other_in"); } #[test] fn create_wires_inputs() { let (core, behavior) = create(); assert_eq!(behavior.type_id(), "org.olivevideoeditor.Olive.timeoffset"); let time_in = core.get_input(TIME_INPUT).unwrap(); assert_eq!(time_in.value_type, ValueType::Rational); assert_eq!(time_in.default, NodeValue::Rational(Rational::new(0, 1))); assert_ne!(time_in.flags & crate::input::flags::NOT_CONNECTABLE, 0); assert!(time_in .properties .iter() .any(|(k, v)| { k == "view" && v == &NodeValue::Text("time".to_string()) })); assert!(time_in .properties .iter() .any(|(k, v)| { k == "viewlock" && v == &NodeValue::Boolean(true) })); let input_in = core.get_input(INPUT_INPUT).unwrap(); assert_eq!(input_in.value_type, ValueType::None); assert_ne!(input_in.flags & crate::input::flags::NOT_KEYFRAMABLE, 0); } #[test] fn get_remapped_time_shifts_forward() { let (mut core, _) = create(); core.set_standard_value(TIME_INPUT, -1, NodeValue::Rational(Rational::new(5, 1))); assert_eq!( TimeOffsetNode::get_remapped_time(&core, Rational::new(10, 1)), Rational::new(15, 1) ); } #[test] fn get_remapped_output_time_shifts_back() { let (mut core, _) = create(); core.set_standard_value(TIME_INPUT, -1, NodeValue::Rational(Rational::new(5, 1))); assert_eq!( TimeOffsetNode::get_remapped_output_time(&core, Rational::new(10, 1)), Rational::new(5, 1) ); } #[test] fn input_time_adjustment_shifts_range_forward() { let (mut core, _) = create(); core.set_standard_value(TIME_INPUT, -1, NodeValue::Rational(Rational::new(5, 1))); let r = TimeOffsetNode::input_time_adjustment_with( &core, INPUT_INPUT, -1, TimeRange::new(Rational::new(10, 1), Rational::new(20, 1)), true, ); assert_eq!(r.in_(), Rational::new(15, 1)); assert_eq!(r.out(), Rational::new(25, 1)); } #[test] fn output_time_adjustment_shifts_range_back() { let (mut core, _) = create(); core.set_standard_value(TIME_INPUT, -1, NodeValue::Rational(Rational::new(5, 1))); let r = TimeOffsetNode::output_time_adjustment_with( &core, INPUT_INPUT, -1, TimeRange::new(Rational::new(10, 1), Rational::new(20, 1)), true, ); assert_eq!(r.in_(), Rational::new(5, 1)); assert_eq!(r.out(), Rational::new(15, 1)); } #[test] fn adjustments_evaluate_offset_per_endpoint() { let (mut core, _) = create(); // A non-constant (keyframed) offset: each endpoint is shifted by the // time_in value evaluated at that endpoint. core.keyframe_track_mut(TIME_INPUT, -1) .set_key(crate::keyframe::Keyframe { time: Rational::new(0, 1), value: NodeValue::Rational(Rational::new(1, 1)), interpolation: crate::keyframe::Interpolation::Linear, bezier_in: (0.0, 0.0), bezier_out: (0.0, 0.0), }); core.keyframe_track_mut(TIME_INPUT, -1) .set_key(crate::keyframe::Keyframe { time: Rational::new(20, 1), value: NodeValue::Rational(Rational::new(3, 1)), interpolation: crate::keyframe::Interpolation::Linear, bezier_in: (0.0, 0.0), bezier_out: (0.0, 0.0), }); let t = TimeRange::new(Rational::new(0, 1), Rational::new(20, 1)); let shifted = TimeOffsetNode::input_time_adjustment_with(&core, INPUT_INPUT, -1, t, true); // 0 + offset(0s) = 1; 20 + offset(20s) = 23. assert_eq!(shifted.in_(), Rational::new(1, 1)); assert_eq!(shifted.out(), Rational::new(23, 1)); } #[test] fn adjustments_are_mutually_inverse_for_constant_offset() { let (mut core, _) = create(); core.set_standard_value(TIME_INPUT, -1, NodeValue::Rational(Rational::new(5, 1))); let t = TimeRange::new(Rational::new(10, 1), Rational::new(20, 1)); let shifted = TimeOffsetNode::input_time_adjustment_with(&core, INPUT_INPUT, -1, t, true); assert_eq!(shifted.in_(), Rational::new(15, 1)); assert_eq!(shifted.out(), Rational::new(25, 1)); let unshifted = TimeOffsetNode::output_time_adjustment_with(&core, INPUT_INPUT, -1, shifted, true); assert_eq!(unshifted, t); } #[test] fn adjustments_other_inputs_are_identity() { let (mut core, _) = create(); core.set_standard_value(TIME_INPUT, -1, NodeValue::Rational(Rational::new(5, 1))); let t = TimeRange::new(Rational::new(10, 1), Rational::new(20, 1)); assert_eq!( TimeOffsetNode::input_time_adjustment_with(&core, "other_in", -1, t, true), t ); assert_eq!( TimeOffsetNode::output_time_adjustment_with(&core, "other_in", -1, t, true), t ); } #[test] fn value_passes_input_through() { let (core, behavior) = create(); let mut row = crate::value::NodeValueRow::default(); row.insert(INPUT_INPUT.to_string(), NodeValue::Float(42.0)); let mut table = NodeValueTable::default(); behavior.value(&core, &row, Rational::new(0, 1), &mut table); assert_eq!(table.get(ValueType::Float), Some(&NodeValue::Float(42.0))); } #[test] fn duplicate_copies_node() { let (_core, behavior) = create(); let copy = behavior.duplicate(&_core).unwrap(); assert_eq!(copy.type_id(), "org.olivevideoeditor.Olive.timeoffset"); assert_eq!(copy.name(), "Time Offset"); } }