Files
oak-editor/crates/oakcodec/src/timecodemetadata.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

355 lines
10 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/>.
//! `olive::TimecodeMetadata` — parse source timecode strings.
//!
//! Mirrors `src/codec/src/timecodemetadata.h`: parse an SMPTE timecode
//! string or a BWF `time reference` chunk into a rational media timestamp.
use oakcore_rs::Rational;
/// `TimecodeMetadata::SourceTime` — the parsed result.
pub struct SourceTime {
/// Media timestamp, rational seconds.
pub time: Rational,
/// Source string (normalized form, or the raw input on failure).
pub source: String,
/// Parse succeeded.
pub valid: bool,
}
/// Trim the same whitespace set as the C++ `trimmed()` helper
/// (`" \t\n\r\f\v"`): space, tab, LF, CR, form feed, vertical tab.
fn trimmed(s: &str) -> String {
s.trim_matches(|c: char| {
c == ' ' || c == '\t' || c == '\n' || c == '\r' || c == '\u{000c}' || c == '\u{000b}'
})
.to_string()
}
/// Port of `str_to_int64_empty_tolerant`: an empty field is a valid `0`,
/// any other unparseable field is an error (`None`).
fn str_to_int64_empty_tolerant(s: &str) -> Option<i64> {
if s.is_empty() {
Some(0)
} else {
s.parse::<i64>().ok()
}
}
/// `std::llround` (round half away from zero, truncate to `i64`).
fn llround(x: f64) -> i64 {
x.round() as i64
}
/// `timebase.flipped().to_double()` — seconds-per-frame to frames-per-second.
fn frame_rate(timebase: &Rational) -> f64 {
timebase.denominator() as f64 / timebase.numerator() as f64
}
/// Port of `olive::core::Timecode::timecode_to_time` for the
/// `k_timecode_non_drop_frame` / `k_timecode_drop_frame` displays.
///
/// `drop_frame` is true when the (already trimmed) string contains a `;`.
/// Returns `None` on any parse failure.
fn timecode_to_time(timecode: &str, timebase: &Rational, drop_frame: bool) -> Option<Rational> {
let mut tokens: Vec<&str> = timecode.split(|c| c == ':' || c == ';').collect();
let element_count = 4;
// Keep only the leading `HH:MM:SS:FF` tokens.
if tokens.len() > element_count {
tokens.truncate(element_count);
}
// Pad missing leading fields with empty strings (which parse to 0).
while tokens.len() < element_count {
tokens.insert(0, "");
}
let negative = timecode.starts_with('-');
let hours = str_to_int64_empty_tolerant(tokens[0])?;
let mins = str_to_int64_empty_tolerant(tokens[1])?;
let secs = str_to_int64_empty_tolerant(tokens[2])?;
let frames = str_to_int64_empty_tolerant(tokens[3])?;
let fr = frame_rate(timebase);
let rounded_frame_rate = llround(fr);
let sec_count = hours * 3600 + mins * 60 + secs;
let mut frame_count = sec_count * rounded_frame_rate + frames;
if drop_frame && timebase.numerator() != 1 {
// `timebase_is_drop_frame(timebase)`: numerator != 1.
// Number of frames dropped on the minute marks ≈ 6% of the framerate.
let drop_frames = llround(fr * (2.0 / 30.0));
// `d` and `m` are derived from the real (non-rounded) framerate.
let real_fr_ts = llround(sec_count as f64 * fr) + frames;
let frames_per10_minutes = llround(fr * 600.0);
let d = real_fr_ts / frames_per10_minutes;
let m = real_fr_ts % frames_per10_minutes;
if m > drop_frames {
frame_count -= drop_frames * ((m - drop_frames) / (llround(fr) * 60 - drop_frames));
}
frame_count -= drop_frames * 9 * d;
}
// `timestamp_to_time`: `timebase.num * frame_count / timebase.den`, reduced.
let mut time = timebase.timestamp_to_time(frame_count);
if negative {
time = time * Rational::new(-1, 1);
}
Some(time)
}
/// Signed Euclidean GCD on absolute values (mirrors `i64_gcd`).
fn gcd_u64(mut a: u64, mut b: u64) -> u64 {
while b != 0 {
let t = a % b;
a = b;
b = t;
}
a
}
impl SourceTime {
/// New invalid (empty) source time.
pub fn invalid() -> Self {
SourceTime {
time: Rational::NULL,
source: String::new(),
valid: false,
}
}
/// Parse an SMPTE timecode string at the given timebase
/// (`from_timecode_string`).
///
/// The string is trimmed; an empty result is invalid. A `;` separator
/// selects drop-frame, otherwise non-drop-frame. On any parse failure
/// the result is invalid and `source` holds the trimmed raw input.
pub fn from_timecode_string(timecode: &str, timebase: &Rational) -> SourceTime {
let trimmed_tc = trimmed(timecode);
if trimmed_tc.is_empty() {
return SourceTime::invalid();
}
let drop_frame = trimmed_tc.contains(';');
match timecode_to_time(&trimmed_tc, timebase, drop_frame) {
Some(time) => SourceTime {
time,
source: "timecode".to_string(),
valid: true,
},
None => SourceTime {
time: Rational::NULL,
source: trimmed_tc,
valid: false,
},
}
}
/// Parse a BWF `time reference` chunk into a timestamp
/// (`from_bwf_time_reference`).
///
/// A non-positive `sample_rate`, or a string that is not a single
/// base-10 unsigned integer, yields an invalid result. The parsed
/// sample count over `sample_rate` is reduced by their GCD; if the
/// reduced numerator or denominator exceed `i32::MAX` the value falls
/// back to the (capped, reduced) `Rational::new(samples, sample_rate)`
/// approximation, since `oakcore_rs::Rational` exposes no `from_double`.
pub fn from_bwf_time_reference(time_reference: &str, sample_rate: i32) -> SourceTime {
if sample_rate <= 0 {
return SourceTime::invalid();
}
let trimmed_ref = trimmed(time_reference);
// `std::strtoull` base 10 with a "whole string consumed" check:
// at least one digit, no leading/trailing junk.
let samples: u64 = match trimmed_ref.parse() {
Ok(v) => v,
Err(_) => return SourceTime::invalid(),
};
let mut numerator = samples;
let mut denominator = sample_rate as u64;
let divisor = gcd_u64(numerator, denominator);
numerator /= divisor;
denominator /= divisor;
let rational_limit = i32::MAX as u64;
let time = if numerator <= rational_limit && denominator <= rational_limit {
Rational::new(numerator as i64, denominator as i64)
} else {
// `Rational::from_double` is not part of the oakcore_rs public
// API; `Rational::new` applies the same INT_MAX-capped reduction
// (FFmpeg `av_reduce`), which is the intended approximation.
let n = i64::try_from(samples).unwrap_or(i64::MAX);
Rational::new(n, sample_rate as i64)
};
SourceTime {
time,
source: "bwf_time_reference".to_string(),
valid: true,
}
}
}
#[cfg(test)]
mod tests {
use super::*;
fn tc(s: &str, timebase: &Rational) -> SourceTime {
SourceTime::from_timecode_string(s, timebase)
}
#[test]
fn invalid_is_null() {
let inv = SourceTime::invalid();
assert!(inv.time.is_null());
assert!(inv.source.is_empty());
assert!(!inv.valid);
}
#[test]
fn empty_or_whitespace_is_invalid() {
let tb = Rational::new(1, 25);
assert!(!tc("", &tb).valid);
assert!(!tc(" \t\n", &tb).valid);
}
#[test]
fn non_drop_integral_fps() {
let tb = Rational::new(1, 25);
let r = tc("00:00:00:10", &tb);
assert!(r.valid);
assert_eq!(r.source, "timecode");
assert_eq!(r.time, Rational::new(2, 5)); // 10 frames @ 25fps = 0.4s
}
#[test]
fn non_drop_leading_zeros_and_whitespace() {
let tb = Rational::new(1, 25);
let r = tc(" 01:02:03:04 ", &tb);
assert!(r.valid);
// 1*3600+2*60+3 = 3723s * 25 + 4 frames = 93079 frames
assert_eq!(r.time, Rational::new(93079, 25));
}
#[test]
fn missing_leading_fields_pad_from_front() {
let tb = Rational::new(1, 25);
let r = tc("00:01:02", &tb);
assert!(r.valid);
// C++ pads missing leading fields at the front, so 3 fields become
// ["",00,01,02] = HH=0, MM=0, SS=1, FF=2 -> 27 frames @25fps.
assert_eq!(r.time, Rational::new(27, 25));
}
#[test]
fn negative_timecode_is_negated() {
let tb = Rational::new(1, 25);
let r = tc("-00:00:00:05", &tb);
assert!(r.valid);
assert_eq!(r.time, Rational::new(-1, 5));
}
#[test]
fn parse_failure_is_invalid_with_raw_source() {
let tb = Rational::new(1, 25);
let r = tc("abc:def", &tb);
assert!(!r.valid);
assert!(r.time.is_null());
assert_eq!(r.source, "abc:def");
}
#[test]
fn drop_frame_29_97_first_minute_no_correction() {
// 29.97fps -> timebase 1001/30000.
let tb = Rational::new(1001, 30000);
let r = tc("00:01:00;00", &tb);
assert!(r.valid);
// No frames dropped in the first minute: `frame_count` stays at
// 60s * 30fps = 1800 frames (a minute boundary at 29.97 is 60.06s).
assert_eq!(r.time, tb.timestamp_to_time(1800));
}
#[test]
fn drop_frame_29_97_later_minute_corrects() {
let tb = Rational::new(1001, 30000);
// At 10 minutes of drop-frame timecode the running correction is
// 2 frames dropped per minute for 9 of the 10 minutes (18 frames).
let r = tc("00:10:00;00", &tb);
assert!(r.valid);
// NDF would be 10*60*30 = 18000 frames; 18 dropped -> 17982 frames.
assert_eq!(r.time, tb.timestamp_to_time(17982));
}
#[test]
fn non_drop_uses_colon() {
let tb = Rational::new(1001, 30000);
let r = tc("00:10:00:00", &tb);
assert!(r.valid);
// Non-drop: 18000 frames, no correction.
assert_eq!(r.time, tb.timestamp_to_time(18000));
}
#[test]
fn bwf_valid_reduces() {
let r = SourceTime::from_bwf_time_reference("48000", 48000);
assert!(r.valid);
assert_eq!(r.source, "bwf_time_reference");
assert_eq!(r.time, Rational::new(1, 1));
}
#[test]
fn bwf_valid_whitespace_trimmed() {
let r = SourceTime::from_bwf_time_reference(" 24000 ", 48000);
assert!(r.valid);
assert_eq!(r.time, Rational::new(1, 2));
}
#[test]
fn bwf_bad_sample_rate_is_invalid() {
for sr in [0, -1] {
let r = SourceTime::from_bwf_time_reference("100", sr);
assert!(!r.valid);
assert!(r.time.is_null());
}
}
#[test]
fn bwf_unparseable_is_invalid() {
for s in ["", " ", "abc", "12x", "1.5", "-5", "1 2"] {
let r = SourceTime::from_bwf_time_reference(s, 48000);
assert!(!r.valid, "should reject {:?}", s);
assert!(r.time.is_null());
}
}
#[test]
fn bwf_no_common_divisor() {
let r = SourceTime::from_bwf_time_reference("3", 48000);
assert!(r.valid);
assert_eq!(r.time, Rational::new(1, 16000));
}
}