Files
oak-editor/crates/oaknode/src/nodes/timeformat.rs
T
Mike-Solar 18ff60f147 feat(engine): clip move, clip effect_input, mandatory static FFmpeg
- oakengine_sequence_move_clip implemented for real (oaktimeline
  TrackMoveBlockCommand; fixes the graph-ownership/gap-anchor/ripple
  trim bugs the stub was hiding); same-track via the frozen C ABI,
  cross-track supported by the module command
- oaknode clip blocks now declare a tex_in texture input and set
  effect_input to it, so timeline clips can host effect chains; facade
  test covers effect insert/remove on a real clip
- oakffmpeg-link: FFMPEG_DIR is now mandatory with a clear panic (a
  Homebrew upgrade left the system ffmpeg .pc pointing at a deleted
  dav1d Cellar path, breaking links); reads a git-ignored workspace
  .env for IDEs that cannot inject env vars (RustRover); links the C++
  stdlib for C++ codec libs (svt-av1)
- oakengine re-exports oaknode so tests share one crate instance;
  it_node uses the direct instance's value type where it calls the
  module FFI (the --workspace dev-dependency feature split builds
  oaknode twice)
2026-08-11 23:04:48 +08:00

579 lines
16 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/>.
//! Time format node (C++ `src/node/src/time/timeformat/timeformat.{h,cpp}`,
//! `olive::TimeFormatNode`).
use std::ffi::{c_char, c_int, c_long};
use crate::factory::NodeMeta;
use crate::node::{Category, NodeBehavior, NodeCore};
use crate::value::{NodeValue, NodeValueRow, NodeValueTable};
use oakcore_rs::Rational;
/// Time input id (C++ `k_time_input`). Type: float; no explicit default
/// (seconds since the Unix epoch).
pub const TIME_INPUT: &str = "time_in";
/// Format input id (C++ `k_format_input`). Type: text; default
/// `"hh:mm:ss"`; uses Qt `QDateTime::toString` token syntax (d/dd, M/MM,
/// yy/yyyy, h/hh, H/HH, m/mm, s/ss, z/zz/zzz, AP/ap/A/a, single-quoted
/// literals).
pub const FORMAT_INPUT: &str = "format_in";
/// Local-time toggle input id (C++ `k_local_time_input`). Type: boolean;
/// no explicit default (false): when true the epoch time is interpreted in
/// the local timezone (C++ `localtime_r`), otherwise UTC (C++ `gmtime_r`).
pub const LOCAL_TIME_INPUT: &str = "localtime_in";
/// Time format node. Formats a Unix-epoch-seconds time into a text string.
/// The C++ class has no own data members (the `.cpp`'s anonymous-namespace
/// `format_date_time()` token expander becomes part of `value()`), so this
/// is a unit-like struct.
pub struct TimeFormatNode;
/// `struct tm` mirror with the macOS/glibc layout (9 ints, then
/// `long tm_gmtoff`, then `const char *tm_zone`) — enough for the
/// `localtime_r`/`gmtime_r` FFI below. Only the calendar fields are read
/// back.
#[repr(C)]
#[derive(Clone, Copy)]
struct Tm {
tm_sec: c_int,
tm_min: c_int,
tm_hour: c_int,
tm_mday: c_int,
tm_mon: c_int,
tm_year: c_int,
tm_wday: c_int,
tm_yday: c_int,
tm_isdst: c_int,
tm_gmtoff: c_long,
tm_zone: *const c_char,
}
// `localtime_r` / `gmtime_r` (C `time.h`). Declared locally instead of
// pulling in a libc crate; both symbols live in the platform C library
// that `std` already links.
extern "C" {
fn localtime_r(timep: *const c_long, result: *mut Tm) -> *mut Tm;
fn gmtime_r(timep: *const c_long, result: *mut Tm) -> *mut Tm;
}
/// Expand Qt date/time format tokens (`QDateTime::toString` syntax):
/// the field tokens d/dd, M/MM, yy/yyyy, h/hh, H/HH, m/mm, s/ss, z/zz/zzz,
/// AP/ap/A/a, and single-quoted literal sections, mirroring Qt's
/// longest-run matching (C++ anonymous-namespace `format_date_time()` in
/// `timeformat.cpp`).
fn format_date_time(tm: &Tm, ms: i32, format: &str) -> String {
// Qt displays h/hh on the 12-hour clock only when the format contains an
// AM/PM token (AP, ap, A or a); otherwise it is the 24-hour clock.
let has_am_pm = format.contains("AP")
|| format.contains("ap")
|| format.contains('A')
|| format.contains('a');
let bytes = format.as_bytes();
let mut out = String::new();
let mut i = 0;
while i < bytes.len() {
let c = bytes[i] as char;
if c == '\'' {
// Single-quoted literal section: copy through to the closing
// quote (or to the end of the format when unterminated).
match format[i + 1..].find('\'') {
Some(rel) => {
out.push_str(&format[i + 1..i + 1 + rel]);
i += 1 + rel + 1;
}
None => {
out.push_str(&format[i + 1..]);
break;
}
}
continue;
}
let mut run = 1;
while i + run < bytes.len() && bytes[i + run] == bytes[i] {
run += 1;
}
match c {
'd' => {
let s = if run >= 2 {
format!("{:02}", tm.tm_mday)
} else {
format!("{}", tm.tm_mday)
};
out.push_str(&s);
}
'M' => {
let mon = tm.tm_mon + 1;
let s = if run >= 2 {
format!("{:02}", mon)
} else {
format!("{}", mon)
};
out.push_str(&s);
}
'y' => {
let year = tm.tm_year + 1900;
if run >= 4 {
out.push_str(&format!("{:04}", year));
} else {
out.push_str(&format!("{:02}", year % 100));
}
}
'H' => {
let s = if run >= 2 {
format!("{:02}", tm.tm_hour)
} else {
format!("{}", tm.tm_hour)
};
out.push_str(&s);
}
'h' => {
let mut hour = tm.tm_hour;
if has_am_pm {
hour %= 12;
if hour == 0 {
hour = 12;
}
}
let s = if run >= 2 {
format!("{:02}", hour)
} else {
format!("{}", hour)
};
out.push_str(&s);
}
'm' => {
let s = if run >= 2 {
format!("{:02}", tm.tm_min)
} else {
format!("{}", tm.tm_min)
};
out.push_str(&s);
}
's' => {
let s = if run >= 2 {
format!("{:02}", tm.tm_sec)
} else {
format!("{}", tm.tm_sec)
};
out.push_str(&s);
}
'z' => {
let s = if run >= 3 {
format!("{:03}", ms)
} else {
format!("{}", ms)
};
out.push_str(&s);
}
'A' | 'a' => {
// Qt: A/AP/ap/a are all replaced by the full AM/PM string;
// the two-letter form is a single token.
if i + run < bytes.len()
&& bytes[i + run] as char == (c as u8 + (b'P' - b'A')) as char
{
run += 1;
}
let am = if c == 'A' { "AM" } else { "am" };
let pm = if c == 'A' { "PM" } else { "pm" };
out.push_str(if tm.tm_hour < 12 { am } else { pm });
}
_ => {
out.push_str(&format[i..i + run]);
}
}
i += run;
}
out
}
/// `Variant::to_bool()` for the local-time toggle (Boolean payload, with a
/// numeric fallback for mis-typed connections).
fn to_bool(v: &NodeValue) -> bool {
match v {
NodeValue::Boolean(b) => *b,
other => other.to_double() != 0.0,
}
}
/// `Variant::to_string()` for the format string (Text payload, with a
/// numeric fallback for mis-typed connections).
fn to_text(v: &NodeValue) -> String {
match v {
NodeValue::Text(s) => s.clone(),
other => other.to_double().to_string(),
}
}
impl NodeBehavior for TimeFormatNode {
/// Human-readable name (C++ `name()`).
fn name(&self) -> &str {
"Time Format"
}
/// Stable type id (C++ `id()`).
fn type_id(&self) -> &str {
"org.olivevideoeditor.Olive.timeformat"
}
/// Categories (C++ `category()`).
fn categories(&self) -> &[Category] {
&[Category::Generator]
}
/// Description (C++ `description()`).
fn description(&self) -> &str {
"Format time (in Unix epoch seconds) into a string."
}
/// Localized input names (C++ `retranslate()`): `time_in` -> "Time",
/// `format_in` -> "Format", `localtime_in` -> "Interpret time as local
/// time".
fn input_name<'a>(&self, id: &'a str) -> &'a str {
match id {
TIME_INPUT => "Time",
FORMAT_INPUT => "Format",
LOCAL_TIME_INPUT => "Interpret time as local time",
_ => id,
}
}
/// Evaluate outputs (C++ `value()`): converts `time_in` (float seconds)
/// to epoch milliseconds, splits into `std::tm` via `localtime_r` or
/// `gmtime_r` depending on `localtime_in`, expands the Qt-style format
/// tokens of `format_in` with longest-run matching (the C++
/// anonymous-namespace `format_date_time()`: h/hh is 12-hour only when
/// an AM/PM token is present; A/AP/ap/a emit the full AM/PM string),
/// and pushes the result as a text value.
fn value(
&self,
core: &NodeCore,
inputs: &NodeValueRow,
time: Rational,
table: &mut NodeValueTable,
) {
let time_val = inputs
.get(TIME_INPUT)
.cloned()
.unwrap_or_else(|| core.value_at_time(TIME_INPUT, -1, time));
let format_val = inputs
.get(FORMAT_INPUT)
.cloned()
.unwrap_or_else(|| core.value_at_time(FORMAT_INPUT, -1, time));
let local_val = inputs
.get(LOCAL_TIME_INPUT)
.cloned()
.unwrap_or_else(|| core.value_at_time(LOCAL_TIME_INPUT, -1, time));
let ms_since_epoch = (time_val.to_double() * 1000.0) as i64;
let secs = (ms_since_epoch / 1000) as c_long;
let ms = (ms_since_epoch % 1000) as i32;
let mut tm: Tm = unsafe { std::mem::zeroed() };
unsafe {
if to_bool(&local_val) {
localtime_r(&secs, &mut tm);
} else {
gmtime_r(&secs, &mut tm);
}
}
let output = format_date_time(&tm, ms, &to_text(&format_val));
table.push(crate::value::ValueType::Text, NodeValue::Text(output), None);
}
/// Deep copy (C++ `copy()`).
fn duplicate(&self, _core: &NodeCore) -> Option<Box<dyn NodeBehavior>> {
Some(Box::new(TimeFormatNode))
}
}
/// Constructor (C++ `TimeFormatNode::TimeFormatNode()`): adds `time_in`
/// (float), `format_in` (text, default `"hh:mm:ss"`), and `localtime_in`
/// (boolean), all with default flags.
pub fn create() -> (NodeCore, Box<dyn NodeBehavior>) {
let mut core = NodeCore::new();
let mut time = crate::input::Input::new(
TIME_INPUT,
crate::value::ValueType::Float,
crate::value::NodeValue::Float(0.0),
);
time.properties = vec![
("min".to_string(), crate::value::NodeValue::Float(0.0)),
(
"max".to_string(),
crate::value::NodeValue::Float(2147483647.0),
),
];
core.add_input(time);
core.add_input(crate::input::Input::new(
FORMAT_INPUT,
crate::value::ValueType::Text,
crate::value::NodeValue::Text("hh:mm:ss".to_string()),
));
core.add_input(crate::input::Input::new(
LOCAL_TIME_INPUT,
crate::value::ValueType::Boolean,
crate::value::NodeValue::Boolean(false),
));
(core, Box::new(TimeFormatNode))
}
/// Register this node type (C++ factory listing for
/// `org.olivevideoeditor.Olive.timeformat`).
pub fn register(meta: &mut Vec<NodeMeta>) {
meta.push(NodeMeta {
type_id: "org.olivevideoeditor.Olive.timeformat",
name: "Time Format",
categories: &[Category::Generator],
create,
});
}
#[cfg(test)]
mod tests {
use super::*;
use crate::node::NodeBehavior;
use crate::value::{NodeValueTable, ValueType};
use oakcore_rs::Rational;
/// A `Tm` with just the calendar fields set (rest zeroed).
fn tm(sec: i32, min: i32, hour: i32, mday: i32, mon: i32, year: i32) -> Tm {
Tm {
tm_sec: sec,
tm_min: min,
tm_hour: hour,
tm_mday: mday,
tm_mon: mon,
tm_year: year,
..unsafe { std::mem::zeroed() }
}
}
#[test]
fn input_names() {
let n = TimeFormatNode;
assert_eq!(n.input_name(TIME_INPUT), "Time");
assert_eq!(n.input_name(FORMAT_INPUT), "Format");
assert_eq!(
n.input_name(LOCAL_TIME_INPUT),
"Interpret time as local time"
);
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.timeformat");
assert_eq!(
core.get_input(TIME_INPUT).unwrap().value_type,
ValueType::Float
);
assert_eq!(
core.get_input(FORMAT_INPUT).unwrap().value_type,
ValueType::Text
);
assert_eq!(
core.get_input(FORMAT_INPUT).unwrap().default,
NodeValue::Text("hh:mm:ss".to_string())
);
assert_eq!(
core.get_input(LOCAL_TIME_INPUT).unwrap().value_type,
ValueType::Boolean
);
}
#[test]
fn format_24_hour_clock_without_am_pm_token() {
let t = tm(6, 5, 13, 9, 7, 124); // 2024-08-09 13:05:06
assert_eq!(format_date_time(&t, 0, "hh:mm:ss"), "13:05:06");
assert_eq!(format_date_time(&t, 0, "HH"), "13");
}
#[test]
fn format_12_hour_clock_with_am_pm_token() {
let pm = tm(0, 0, 13, 1, 0, 124);
assert_eq!(format_date_time(&pm, 0, "h:mm AP"), "1:00 PM");
assert_eq!(format_date_time(&pm, 0, "h:mm ap"), "1:00 pm");
assert_eq!(format_date_time(&pm, 0, "h:mm A"), "1:00 PM");
assert_eq!(format_date_time(&pm, 0, "h:mm a"), "1:00 pm");
let am = tm(0, 0, 0, 1, 0, 124);
assert_eq!(format_date_time(&am, 0, "h AP"), "12 AM");
let midnight = tm(0, 0, 0, 1, 0, 124);
assert_eq!(format_date_time(&midnight, 0, "hh AP"), "12 AM");
}
#[test]
fn format_date_tokens() {
let t = tm(6, 5, 13, 9, 7, 124); // 2024-08-09 13:05:06
assert_eq!(format_date_time(&t, 0, "yyyy-MM-dd"), "2024-08-09");
assert_eq!(format_date_time(&t, 0, "yy-M-d"), "24-8-9");
assert_eq!(format_date_time(&t, 0, "yyyy"), "2024");
}
#[test]
fn format_millisecond_tokens() {
let t = tm(0, 0, 0, 1, 0, 124);
assert_eq!(format_date_time(&t, 5, "z"), "5");
assert_eq!(format_date_time(&t, 5, "zz"), "5");
assert_eq!(format_date_time(&t, 5, "zzz"), "005");
assert_eq!(format_date_time(&t, 123, "zzz"), "123");
}
#[test]
fn format_literal_sections() {
let t = tm(0, 0, 13, 9, 7, 124);
assert_eq!(format_date_time(&t, 0, "'Literal text'"), "Literal text");
assert_eq!(format_date_time(&t, 0, "'It''s' HH"), "Its 13");
assert_eq!(
format_date_time(&t, 0, "yyyy'unterminated"),
"2024unterminated"
);
}
#[test]
fn format_unknown_tokens_copied_verbatim() {
let t = tm(0, 0, 13, 9, 7, 124);
assert_eq!(format_date_time(&t, 0, "Q % 5"), "Q % 5");
}
#[test]
fn value_formats_utc_epoch() {
let (mut core, behavior) = create();
// 12:34:56 on 1970-01-01 UTC = 45296 seconds.
core.set_standard_value(TIME_INPUT, -1, NodeValue::Float(45296.0));
core.set_standard_value(
FORMAT_INPUT,
-1,
NodeValue::Text("yyyy-MM-dd HH:mm:ss".to_string()),
);
let mut table = NodeValueTable::default();
behavior.value(
&core,
&crate::value::NodeValueRow::default(),
Rational::new(0, 1),
&mut table,
);
assert_eq!(
table.get(ValueType::Text),
Some(&NodeValue::Text("1970-01-01 12:34:56".to_string()))
);
}
#[test]
fn value_formats_milliseconds_utc() {
let (mut core, behavior) = create();
// Same instant plus 789 ms.
core.set_standard_value(TIME_INPUT, -1, NodeValue::Float(45296.789));
core.set_standard_value(
FORMAT_INPUT,
-1,
NodeValue::Text("HH:mm:ss.zzz".to_string()),
);
let mut table = NodeValueTable::default();
behavior.value(
&core,
&crate::value::NodeValueRow::default(),
Rational::new(0, 1),
&mut table,
);
assert_eq!(
table.get(ValueType::Text),
Some(&NodeValue::Text("12:34:56.789".to_string()))
);
}
#[test]
fn value_uses_connected_inputs() {
let (core, behavior) = create();
let mut row = crate::value::NodeValueRow::default();
row.insert(TIME_INPUT.to_string(), NodeValue::Float(45296.0));
row.insert(
FORMAT_INPUT.to_string(),
NodeValue::Text("yyyy".to_string()),
);
row.insert(LOCAL_TIME_INPUT.to_string(), NodeValue::Boolean(false));
let mut table = NodeValueTable::default();
behavior.value(&core, &row, Rational::new(0, 1), &mut table);
assert_eq!(
table.get(ValueType::Text),
Some(&NodeValue::Text("1970".to_string()))
);
}
#[test]
fn value_localtime_flag_routes_to_localtime_r() {
let (mut core, behavior) = create();
core.set_standard_value(TIME_INPUT, -1, NodeValue::Float(45296.0));
core.set_standard_value(FORMAT_INPUT, -1, NodeValue::Text("yyyy".to_string()));
// Expected values computed through the same C library calls the C++
// makes — this validates the routing (which function is called for
// each flag value), not the C library itself.
let mut secs: c_long = 45296;
let mut local: Tm = unsafe { std::mem::zeroed() };
unsafe {
localtime_r(&secs, &mut local);
}
let mut utc: Tm = unsafe { std::mem::zeroed() };
unsafe {
gmtime_r(&secs, &mut utc);
}
let local_expected = format!("{:04}", local.tm_year + 1900);
let utc_expected = format!("{:04}", utc.tm_year + 1900);
assert_eq!(utc_expected, "1970");
core.set_standard_value(LOCAL_TIME_INPUT, -1, NodeValue::Boolean(true));
let mut table = NodeValueTable::default();
behavior.value(
&core,
&crate::value::NodeValueRow::default(),
Rational::new(0, 1),
&mut table,
);
assert_eq!(
table.get(ValueType::Text),
Some(&NodeValue::Text(local_expected))
);
core.set_standard_value(LOCAL_TIME_INPUT, -1, NodeValue::Boolean(false));
let mut table = NodeValueTable::default();
behavior.value(
&core,
&crate::value::NodeValueRow::default(),
Rational::new(0, 1),
&mut table,
);
assert_eq!(
table.get(ValueType::Text),
Some(&NodeValue::Text(utc_expected))
);
}
#[test]
fn duplicate_copies_node() {
let (_core, behavior) = create();
let copy = behavior.duplicate(&_core).unwrap();
assert_eq!(copy.type_id(), "org.olivevideoeditor.Olive.timeformat");
assert_eq!(copy.name(), "Time Format");
}
}