From d2dc637c776a6c7bd4cafa502a5f53a33154e0a5 Mon Sep 17 00:00:00 2001 From: Zach Shallbetter Date: Mon, 12 Oct 2026 21:19:01 -0700 Subject: [PATCH 1/5] Add craft-time: exact media timebase and SMPTE timecode (refs #2) Implements the shared exact media timebase crate (craft-time) for FilmCraft, SoundCraft, EffectCraft, and animation timeline consumers: - Exact Signed Timebase: Tick represents exact media time across [Tick::MIN, Tick::MAX] using TICKS_PER_SECOND = 254_016_000_000, supporting exact integer conversions for standard film, broadcast, audio, and high-framerate rates. - Total Arithmetic Contract: All operations are strictly panic-free under #![deny(clippy::panic)], providing checked_* methods and monotonic saturating division. - Timecode and Film Footage: Full SMPTE 12M drop-frame and non-drop-frame formatting, 35mm and 16mm feet+frames, broadcast zero-padded hours, and EffectCraft unpadded hours. - Differential Testing: 15 test suites incorporating full FilmCraft and EffectCraft parity vectors, boundary saturation tests, and proptest monotonicity invariants. --- Cargo.lock | 133 +++++- Cargo.toml | 1 + README.md | 2 +- crates/time/Cargo.toml | 17 + crates/time/README.md | 110 +++++ crates/time/src/lib.rs | 710 ++++++++++++++++++++++++++++++ crates/time/tests/differential.rs | 315 +++++++++++++ 7 files changed, 1280 insertions(+), 8 deletions(-) create mode 100644 crates/time/Cargo.toml create mode 100644 crates/time/README.md create mode 100644 crates/time/src/lib.rs create mode 100644 crates/time/tests/differential.rs diff --git a/Cargo.lock b/Cargo.lock index 6471411f0..89478f3cc 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -206,7 +206,16 @@ version = "0.10.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "09ec2f926cc3060f09db9ebc5b52823d85268d24bb917e472c0c4bea35780a7d" dependencies = [ - "bit-vec", + "bit-vec 0.9.1", +] + +[[package]] +name = "bit-set" +version = "0.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "56d87354e4229f54a44f7bf2435906a4656dba36026ab6eaca629a2c436a691c" +dependencies = [ + "bit-vec 0.10.1", ] [[package]] @@ -215,6 +224,16 @@ version = "0.9.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b71798fca2c1fe1086445a7258a4bc81e6e49dcd24c8d0dd9a1e57395b603f51" +[[package]] +name = "bit-vec" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5727b15fa97d4f4fee0a3b7c3d550ed0269f54329207b86388de918604e31269" +dependencies = [ + "borsh", + "serde", +] + [[package]] name = "bitflags" version = "1.3.2" @@ -254,6 +273,30 @@ dependencies = [ "objc2 0.6.5", ] +[[package]] +name = "borsh" +version = "1.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "553c5d846a6ba5150c65e3b1b8ec073bcf1abc20f9b7220de384a4443ea4e20a" +dependencies = [ + "borsh-derive", + "bytes", + "cfg_aliases", +] + +[[package]] +name = "borsh-derive" +version = "1.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "12cdfe656708a01f89b451a7d36466e6fe6c414de0aa18fc54f864f6f9ca9f56" +dependencies = [ + "once_cell", + "proc-macro-crate", + "proc-macro2", + "quote", + "syn 3.0.6", +] + [[package]] name = "brotli" version = "8.0.4" @@ -668,6 +711,14 @@ dependencies = [ "toml_edit", ] +[[package]] +name = "craft-time" +version = "0.1.0" +dependencies = [ + "proptest", + "serde", +] + [[package]] name = "craft-ui" version = "0.1.0" @@ -1918,7 +1969,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "525e9ff3e1a4be2fbea1fdf0e98686a6d98b4d8f937e1bf7402245af1909e8c3" dependencies = [ "byteorder-lite", - "quick-error", + "quick-error 2.0.1", ] [[package]] @@ -2327,7 +2378,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a616d2fb8c89516ac2723a581f69d6c18576046bed761bd6b305e5618e6ae130" dependencies = [ "arrayvec", - "bit-set", + "bit-set 0.10.0", "bitflags 2.13.2", "cfg-if", "cfg_aliases", @@ -3090,12 +3141,38 @@ version = "1.0.18" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3d595e54a326bc53c1c197b32d295e14b169e3cfeaa8dc82b529f947fba6bcf5" +[[package]] +name = "proptest" +version = "1.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8530004ccb15eae51c7e40009fbe317f341f804db54dc033eec1c50be28cfa0" +dependencies = [ + "bit-set 0.11.1", + "bit-vec 0.10.1", + "bitflags 2.13.2", + "chacha20", + "core_detect", + "num-traits", + "rand", + "rand_xorshift", + "regex-syntax", + "rusty-fork", + "tempfile", + "unarray", +] + [[package]] name = "pxfm" version = "0.1.30" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d55d956fa96f5ec02be2e13af0e20391a5aa83d6a074e3ad368959d0fab299ea" +[[package]] +name = "quick-error" +version = "1.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a1d01941d82fa2ab50be1e79e6714289dd7cde78eba4c074bc5a4374f650dfe0" + [[package]] name = "quick-error" version = "2.0.1" @@ -3215,6 +3292,15 @@ dependencies = [ "rand_core 0.10.1", ] +[[package]] +name = "rand_xorshift" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "60aa6af80be32871323012e02e6e65f8a7cc7890931ae421d217ad8fe0df2ccf" +dependencies = [ + "rand_core 0.10.1", +] + [[package]] name = "range-alloc" version = "0.1.5" @@ -3328,6 +3414,12 @@ dependencies = [ "thiserror 2.0.21", ] +[[package]] +name = "regex-syntax" +version = "0.8.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d6f6ff9a378485b298a5286656da665ba74413d36db0979633275d2e708145d4" + [[package]] name = "renderdoc-sys" version = "1.1.0" @@ -3548,6 +3640,18 @@ version = "1.0.23" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "cf54715a573b99ac80df0bc206da022bcd442c974952c7b9720069370852e21f" +[[package]] +name = "rusty-fork" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cc6bf79ff24e648f6da1f8d1f011e9cac26491b619e6b9280f2b47f1774e6ee2" +dependencies = [ + "fnv", + "quick-error 1.2.3", + "tempfile", + "wait-timeout", +] + [[package]] name = "rustybuzz" version = "0.20.1" @@ -4327,7 +4431,7 @@ dependencies = [ "fax", "flate2", "half", - "quick-error", + "quick-error 2.0.1", "weezl", "zune-jpeg", ] @@ -4604,6 +4708,12 @@ version = "1.20.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b6f5e870be6c3b371b77fe0ee0bafb859fa4964b4404c27de1d380043c4dda20" +[[package]] +name = "unarray" +version = "0.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eaea85b334db583fe3274d12b4cd1880032beab409c0d774be044d4480ab9a94" + [[package]] name = "unic-langid" version = "0.9.6" @@ -4823,6 +4933,15 @@ version = "0.9.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" +[[package]] +name = "wait-timeout" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09ac3b126d3914f9849036f826e054cbabdc8519970b8998ddaf3b5bd3c65f11" +dependencies = [ + "libc", +] + [[package]] name = "walkdir" version = "2.5.0" @@ -5137,8 +5256,8 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "14c018fce9b6270aa203c2fdd56f3cce996713534bd757e4ea58c8560b121f14" dependencies = [ "arrayvec", - "bit-set", - "bit-vec", + "bit-set 0.10.0", + "bit-vec 0.9.1", "bitflags 2.13.2", "bytemuck", "cfg_aliases", @@ -5210,7 +5329,7 @@ dependencies = [ "android_system_properties", "arrayvec", "ash", - "bit-set", + "bit-set 0.10.0", "bitflags 2.13.2", "block2 0.6.2", "bytemuck", diff --git a/Cargo.toml b/Cargo.toml index 868e568dd..cf90e022a 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -27,6 +27,7 @@ craft-i18n = { path = "crates/i18n" } craft-print = { path = "crates/print" } craft-quit = { path = "crates/quit" } craft-settings = { path = "crates/settings" } +craft-time = { path = "crates/time" } craft-ui = { path = "crates/ui" } egui = { version = "0.36", default-features = false } rayon = "1" diff --git a/README.md b/README.md index 78b243407..2bfd050a9 100644 --- a/README.md +++ b/README.md @@ -39,11 +39,11 @@ craft- = { git = "https://github.com/storytold/craft-libs", rev = " Tick(i64::MAX)`), preserving strict mathematical monotonicity across the entire signed 64-bit range without inflection. + +## Guarantees & Doctrines + +- **Zero App Code**: Zero dependencies on application crates. +- **Never Crash**: `#![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)]`. +- **Total Arithmetic**: Division by zero or hostile rates from damaged projects return well-defined fallback states rather than panicking. Arithmetic saturates at 64-bit limits with 128-bit intermediate checks. +- **Forbid Unsafe**: `#![forbid(unsafe_code)]`. diff --git a/crates/time/src/lib.rs b/crates/time/src/lib.rs new file mode 100644 index 000000000..002eccb38 --- /dev/null +++ b/crates/time/src/lib.rs @@ -0,0 +1,710 @@ +#![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)] + +//! Exact media time for the Craft suite. +//! +//! All time is an integer number of [`Tick`]s at [`TICKS_PER_SECOND`] = 254 016 000 000/s. That +//! rate divides evenly into every broadcast frame duration (23.976, 24, 25, 29.97, 30, 48, 50, +//! 59.94, 60, 119.88, 120 fps) and standard audio sample rates (44.1 kHz, 48 kHz, and their +//! multiples), so edits, frame math, audio alignment, and visual effects never drift. +//! +//! Timecode (SMPTE drop/non-drop, frames, feet+frames, audio samples) is a display representation +//! of ticks. Every conversion is total: invalid, zero, or negative rates produce well-defined defaults +//! rather than division-by-zero panics. + +use serde::{Deserialize, Serialize}; +use std::fmt; +use std::ops::{Add, AddAssign, Div, Mul, Neg, Rem, Sub, SubAssign}; + +/// Clock ticks per second: 254 016 000 000. +pub const TICKS_PER_SECOND: i64 = 254_016_000_000; + +/// A point or duration in time, in ticks. +/// Arithmetic saturates at storage limits; representable results remain exact. +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)] +#[serde(transparent)] +pub struct Tick(pub i64); + +impl Tick { + pub const ZERO: Tick = Tick(0); + pub const MAX: Tick = Tick(i64::MAX / 4); + pub const MIN: Tick = Tick(i64::MIN / 4); + + /// Seconds to ticks, clamped to [`Tick::MIN`]..=[`Tick::MAX`]. + pub fn from_seconds_f64(s: f64) -> Tick { + let t = (s * TICKS_PER_SECOND as f64).round(); + Tick((t as i64).clamp(Tick::MIN.0, Tick::MAX.0)) + } + + pub fn seconds(self) -> f64 { + self.0 as f64 / TICKS_PER_SECOND as f64 + } + + /// Exact conversion from a count of `units` at `per_second` (e.g. samples at 48 000). + pub fn from_units(units: i64, per_second: i64) -> Tick { + Tick(clamp_i64((units as i128 * TICKS_PER_SECOND as i128).checked_div(per_second as i128).unwrap_or(0))) + } + + /// Floor conversion to a count of units at `per_second`. + pub fn to_units_floor(self, per_second: i64) -> i64 { + clamp_i64((self.0 as i128 * per_second as i128).div_euclid(TICKS_PER_SECOND as i128)) + } + + /// Conversion from a rational timestamp `pts * num / den` seconds (container timebases). + pub fn from_rational(pts: i64, num: i64, den: i64) -> Tick { + if den == 0 { + return Tick::ZERO; + } + let Some(t) = (pts as i128 * num as i128).checked_mul(TICKS_PER_SECOND as i128) else { + return Tick(if (pts < 0) ^ (num < 0) ^ (den < 0) { i64::MIN } else { i64::MAX }); + }; + Tick(clamp_i64(t.div_euclid(den as i128))) + } + + /// Inverse of [`Tick::from_rational`], floored to the timebase. + pub fn to_rational_floor(self, num: i64, den: i64) -> i64 { + clamp_i64((self.0 as i128 * den as i128).checked_div_euclid(num as i128 * TICKS_PER_SECOND as i128).unwrap_or(0)) + } + + /// Inverse of [`Tick::from_rational`], rounded to the nearest timebase unit (halves up). + pub fn to_rational_round(self, num: i64, den: i64) -> i64 { + let n = self.0 as i128 * den as i128; + let unit = num as i128 * TICKS_PER_SECOND as i128; + let (n, unit) = if unit < 0 { (-n, -unit) } else { (n, unit) }; + let (Some(q), Some(r)) = (n.checked_div_euclid(unit), n.checked_rem_euclid(unit)) else { return 0 }; + clamp_i64(if r * 2 >= unit { q + 1 } else { q }) + } + + pub fn abs(self) -> Tick { + Tick(self.0.saturating_abs()) + } + + pub fn signum(self) -> i64 { + self.0.signum() + } + + pub fn min(self, o: Tick) -> Tick { + Tick(self.0.min(o.0)) + } + + pub fn max(self, o: Tick) -> Tick { + Tick(self.0.max(o.0)) + } + + pub fn clamp(self, lo: Tick, hi: Tick) -> Tick { + Tick(self.0.max(lo.0).min(hi.0)) + } + + /// Scale by a rational factor (`num/den`), flooring. + pub fn mul_ratio(self, num: i64, den: i64) -> Tick { + Tick(clamp_i64((self.0 as i128 * num as i128).checked_div_euclid(den as i128).unwrap_or(0))) + } + + /// Divide by a rational factor (`num/den`), flooring. + pub fn div_ratio(self, num: i64, den: i64) -> Tick { + self.mul_ratio(den, num) + } + + /// Checked addition. Returns `None` if overflow occurred. + pub fn checked_add(self, rhs: Tick) -> Option { + self.0.checked_add(rhs.0).map(Tick) + } + + /// Checked subtraction. Returns `None` if overflow occurred. + pub fn checked_sub(self, rhs: Tick) -> Option { + self.0.checked_sub(rhs.0).map(Tick) + } + + /// Checked multiplication. Returns `None` if overflow occurred. + pub fn checked_mul(self, rhs: i64) -> Option { + self.0.checked_mul(rhs).map(Tick) + } + + /// Checked division. Returns `None` if `rhs == 0` or if overflow occurred (`Tick::MIN / -1`). + pub fn checked_div(self, rhs: i64) -> Option { + self.0.checked_div(rhs).map(Tick) + } + + /// Checked remainder. Returns `None` if `rhs.0 == 0` or if overflow occurred (`Tick::MIN % Tick(-1)`). + pub fn checked_rem(self, rhs: Tick) -> Option { + self.0.checked_rem(rhs.0).map(Tick) + } + + /// Saturating addition. + pub fn saturating_add(self, rhs: Tick) -> Tick { + Tick(self.0.saturating_add(rhs.0)) + } + + /// Saturating subtraction. + pub fn saturating_sub(self, rhs: Tick) -> Tick { + Tick(self.0.saturating_sub(rhs.0)) + } + + /// Saturating multiplication. + pub fn saturating_mul(self, rhs: i64) -> Tick { + Tick(self.0.saturating_mul(rhs)) + } + + /// Saturating division. Returns `None` if `rhs == 0`, and saturates on overflow (`Tick::MIN / -1 -> Tick(i64::MAX)`). + pub fn saturating_div(self, rhs: i64) -> Option { + if rhs == 0 { None } else { Some(Tick(self.0.saturating_div(rhs))) } + } +} + +impl Add for Tick { + type Output = Tick; + fn add(self, o: Tick) -> Tick { + Tick(self.0.saturating_add(o.0)) + } +} + +impl Sub for Tick { + type Output = Tick; + fn sub(self, o: Tick) -> Tick { + Tick(self.0.saturating_sub(o.0)) + } +} + +impl Neg for Tick { + type Output = Tick; + fn neg(self) -> Tick { + Tick(self.0.saturating_neg()) + } +} + +impl AddAssign for Tick { + fn add_assign(&mut self, o: Tick) { + self.0 = self.0.saturating_add(o.0); + } +} + +impl SubAssign for Tick { + fn sub_assign(&mut self, o: Tick) { + self.0 = self.0.saturating_sub(o.0); + } +} + +impl Mul for Tick { + type Output = Tick; + fn mul(self, rhs: i64) -> Tick { + Tick(self.0.saturating_mul(rhs)) + } +} + +impl Div for Tick { + type Output = Tick; + fn div(self, rhs: i64) -> Tick { + // Operator division policy: Div saturates on overflow (e.g. Tick::MIN / -1 -> Tick(i64::MAX)) + // using self.0.saturating_div(rhs) to preserve monotonicity across the full range. + // Division by zero yields Tick::ZERO. For fallible division that detects zero divisors, + // use Tick::checked_div(). + if rhs == 0 { Tick::ZERO } else { Tick(self.0.saturating_div(rhs)) } + } +} + +impl Rem for Tick { + type Output = Tick; + fn rem(self, rhs: Tick) -> Tick { + // Operator remainder policy: Rem produces exact remainder where defined, + // and Tick::ZERO for zero divisor or Tick::MIN % Tick(-1) (which is mathematically 0). + // For fallible remainder, use Tick::checked_rem(). + if rhs.0 == 0 || (self.0 == i64::MIN && rhs.0 == -1) { Tick::ZERO } else { Tick(self.0 % rhs.0) } + } +} + +impl fmt::Display for Tick { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + write!(f, "{} ticks", self.0) + } +} + +/// A half-open time range `[start, start + duration)`. +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Hash, Serialize, Deserialize)] +pub struct TimeRange { + pub start: Tick, + pub duration: Tick, +} + +impl TimeRange { + pub fn new(start: Tick, duration: Tick) -> Self { + Self { start, duration } + } + + pub fn from_bounds(start: Tick, end: Tick) -> Self { + Self { start, duration: end - start } + } + + pub fn end(&self) -> Tick { + self.start + self.duration + } + + pub fn contains(&self, t: Tick) -> bool { + t >= self.start && t < self.end() + } + + pub fn overlaps(&self, o: &TimeRange) -> bool { + self.start < o.end() && o.start < self.end() + } + + pub fn intersect(&self, o: &TimeRange) -> Option { + let s = self.start.max(o.start); + let e = self.end().min(o.end()); + (e > s).then(|| TimeRange::from_bounds(s, e)) + } + + pub fn is_empty(&self) -> bool { + self.duration.0 <= 0 + } +} + +/// A frame rate as an exact rational (`num / den` frames per second). +#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, Serialize, Deserialize)] +pub struct FrameRate { + pub num: i64, + pub den: i64, +} + +impl Default for FrameRate { + fn default() -> Self { + FrameRate::FPS_23_976 + } +} + +impl FrameRate { + pub const FPS_23_976: FrameRate = FrameRate { num: 24000, den: 1001 }; + pub const FPS_24: FrameRate = FrameRate { num: 24, den: 1 }; + pub const FPS_25: FrameRate = FrameRate { num: 25, den: 1 }; + pub const FPS_29_97: FrameRate = FrameRate { num: 30000, den: 1001 }; + pub const FPS_30: FrameRate = FrameRate { num: 30, den: 1 }; + pub const FPS_48: FrameRate = FrameRate { num: 48, den: 1 }; + pub const FPS_50: FrameRate = FrameRate { num: 50, den: 1 }; + pub const FPS_59_94: FrameRate = FrameRate { num: 60000, den: 1001 }; + pub const FPS_60: FrameRate = FrameRate { num: 60, den: 1 }; + pub const FPS_119_88: FrameRate = FrameRate { num: 120000, den: 1001 }; + pub const FPS_120: FrameRate = FrameRate { num: 120, den: 1 }; + + /// Standard broadcast and cinematic rates. + pub const COMMON: [FrameRate; 11] = [ + Self::FPS_23_976, + Self::FPS_24, + Self::FPS_25, + Self::FPS_29_97, + Self::FPS_30, + Self::FPS_48, + Self::FPS_50, + Self::FPS_59_94, + Self::FPS_60, + Self::FPS_119_88, + Self::FPS_120, + ]; + + pub fn new(num: i64, den: i64) -> Self { + let g = i128::from(gcd(num.unsigned_abs(), den.unsigned_abs()).max(1)); + FrameRate { num: (i128::from(num) / g) as i64, den: (i128::from(den) / g) as i64 } + } + + /// Closest standard rate for a floating-point frame rate. + pub fn from_f64(fps: f64) -> Self { + for r in Self::COMMON { + if (r.as_f64() - fps).abs() < 0.005 { + return r; + } + } + FrameRate::new((fps * 1000.0).round() as i64, 1000) + } + + /// Returns this frame rate if positive, or the default rate ([`FrameRate::FPS_23_976`]). + /// + /// ### Application Compatibility Note + /// For non-positive or zero rates (e.g. `{0, 0}`), `craft-time` falls back to + /// [`FrameRate::FPS_23_976`] (timecode base 24), providing a standard broadcast default. + /// In contrast, EffectCraft falls back to timecode base 1. + pub fn sane(self) -> FrameRate { + if self.num > 0 && self.den > 0 { self } else { FrameRate::default() } + } + + pub fn as_f64(self) -> f64 { + self.num as f64 / self.den as f64 + } + + /// Exact duration of one frame (floored for exotic rates). + pub fn frame_duration(self) -> Tick { + let r = self.sane(); + Tick(clamp_i64((TICKS_PER_SECOND as i128 * r.den as i128) / r.num as i128)) + } + + /// Index of the frame containing `t` (floor). + pub fn frame_at(self, t: Tick) -> i64 { + clamp_i64(frame_index(self.sane(), t)) + } + + /// Start tick of frame `f` (ceiling division). + pub fn tick_of(self, f: i64) -> Tick { + Tick(clamp_i64(frame_boundary(self.sane(), i128::from(f)))) + } + + /// Snap `t` down to a frame boundary. + pub fn snap(self, t: Tick) -> Tick { + let r = self.sane(); + Tick(clamp_i64(frame_boundary(r, frame_index(r, t)))) + } + + /// Snap `t` to the nearest frame boundary. + pub fn snap_nearest(self, t: Tick) -> Tick { + let r = self.sane(); + let f = frame_index(r, t); + let a = frame_boundary(r, f); + let b = frame_boundary(r, f.saturating_add(1)); + let t = i128::from(t.0); + Tick(clamp_i64(if t - a <= b - t { a } else { b })) + } + + /// Timecode base (nominal frames per second): 30 for 29.97, 24 for 23.976. + pub fn timecode_base(self) -> i64 { + let r = self.sane(); + (r.num / r.den).saturating_add(i64::from(r.num % r.den != 0)).max(1) + } + + pub fn is_ntsc(self) -> bool { + self.den == 1001 + } + + pub fn supports_drop_frame(self) -> bool { + self.is_ntsc() && self.timecode_base() % 30 == 0 + } + + pub fn label(self) -> String { + if self.den == 1 { + format!("{}", self.num) + } else { + let v = self.as_f64(); + let s = format!("{v:.3}"); + s.trim_end_matches('0').trim_end_matches('.').to_string() + } + } +} + +impl fmt::Display for FrameRate { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + write!(f, "{} fps", self.label()) + } +} + +fn clamp_i64(value: i128) -> i64 { + value.clamp(i128::from(i64::MIN), i128::from(i64::MAX)) as i64 +} + +fn frame_index(rate: FrameRate, t: Tick) -> i128 { + (i128::from(t.0) * i128::from(rate.num)).div_euclid(i128::from(TICKS_PER_SECOND) * i128::from(rate.den)) +} + +fn frame_boundary(rate: FrameRate, frame: i128) -> i128 { + let Some(n) = frame.checked_mul(i128::from(TICKS_PER_SECOND)).and_then(|n| n.checked_mul(i128::from(rate.den))) else { + return if frame < 0 { i128::MIN } else { i128::MAX }; + }; + let d = i128::from(rate.num); + n.div_euclid(d) + i128::from(n.rem_euclid(d) != 0) +} + +fn gcd(mut a: u64, mut b: u64) -> u64 { + while b != 0 { + (a, b) = (b, a % b); + } + a +} + +/// How time is formatted for display. +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Hash, Serialize, Deserialize)] +pub enum TimeDisplay { + #[default] + Timecode, + Frames, + Feet16, + Feet35, + AudioSamples, + Seconds, +} + +impl TimeDisplay { + pub const ALL: [TimeDisplay; 6] = + [TimeDisplay::Timecode, TimeDisplay::Feet35, TimeDisplay::Feet16, TimeDisplay::Frames, TimeDisplay::AudioSamples, TimeDisplay::Seconds]; + + pub fn label(self) -> &'static str { + match self { + TimeDisplay::Timecode => "Timecode", + TimeDisplay::Frames => "Frames", + TimeDisplay::Feet16 => "Feet + Frames 16mm", + TimeDisplay::Feet35 => "Feet + Frames 35mm", + TimeDisplay::AudioSamples => "Audio Samples", + TimeDisplay::Seconds => "Seconds", + } + } +} + +fn df_params(rate: FrameRate) -> (i64, i64) { + let base = rate.timecode_base(); + (base / 15, base) +} + +/// Convert a frame count to SMPTE fields `(negative, h, m, s, f)`. +pub fn frames_to_fields(frame: i64, rate: FrameRate, drop_frame: bool) -> (bool, i64, i64, i64, i64) { + let neg = frame < 0; + let mut n = i128::from(frame).abs(); + let (drop, base) = df_params(rate); + let (drop, base) = (i128::from(drop), i128::from(base)); + if drop_frame && rate.supports_drop_frame() { + let per_min = base * 60 - drop; + let per_10 = per_min * 10 + drop; + let d = n / per_10; + let m = n % per_10; + n = n.saturating_add(drop * 9 * d); + if m > drop { + n = n.saturating_add(drop * ((m - drop) / per_min)); + } + } + let f = n % base; + let s = (n / base) % 60; + let mi = (n / (base * 60)) % 60; + let h = n / (base * 3600); + (neg, clamp_i64(h), clamp_i64(mi), clamp_i64(s), clamp_i64(f)) +} + +/// Convert SMPTE fields back to a frame count. +pub fn fields_to_frames(h: i64, m: i64, s: i64, f: i64, rate: FrameRate, drop_frame: bool) -> i64 { + let (drop, base) = df_params(rate); + let seconds = i128::from(h) * 3600 + i128::from(m) * 60 + i128::from(s); + let mut n = seconds.saturating_mul(i128::from(base)).saturating_add(i128::from(f)); + if drop_frame && rate.supports_drop_frame() { + let total_min = i128::from(h) * 60 + i128::from(m); + n = n.saturating_sub(i128::from(drop) * (total_min - total_min / 10)); + } + clamp_i64(n) +} + +/// Format a frame count as SMPTE timecode (`HH:MM:SS:FF`, or `HH;MM;SS;FF` for drop-frame). +pub fn format_timecode_frames(frame: i64, rate: FrameRate, drop_frame: bool) -> String { + let df = drop_frame && rate.supports_drop_frame(); + let (neg, h, m, s, f) = frames_to_fields(frame, rate, df); + let sep = if df { ';' } else { ':' }; + let fw = if rate.timecode_base() >= 100 { 3 } else { 2 }; + format!("{}{h:02}{sep}{m:02}{sep}{s:02}{sep}{f:0fw$}", if neg { "-" } else { "" }) +} + +/// Format a frame count the way After Effects / EffectCraft displays the current time: hours not zero-padded +/// (`0:00:02:15`, or `0;00;02;15` for drop-frame). +pub fn format_timecode_ae(frame: i64, rate: FrameRate, drop_frame: bool) -> String { + let df = drop_frame && rate.supports_drop_frame(); + let (neg, h, m, s, f) = frames_to_fields(frame, rate, df); + let sep = if df { ';' } else { ':' }; + let fw = if rate.timecode_base() >= 100 { 3 } else { 2 }; + format!("{}{h}{sep}{m:02}{sep}{s:02}{sep}{f:0fw$}", if neg { "-" } else { "" }) +} + +/// Format a tick according to a display mode. +pub fn format_time(t: Tick, rate: FrameRate, drop_frame: bool, display: TimeDisplay, sample_rate: i64) -> String { + let frame = rate.frame_at(t); + match display { + TimeDisplay::Timecode => format_timecode_frames(frame, rate, drop_frame), + TimeDisplay::Frames => format!("{frame}"), + TimeDisplay::Feet35 | TimeDisplay::Feet16 => { + let per_ft = if display == TimeDisplay::Feet35 { 16 } else { 40 }; + let neg = frame < 0; + let a = frame.unsigned_abs(); + format!("{}{}+{:02}", if neg { "-" } else { "" }, a / per_ft, a % per_ft) + } + TimeDisplay::AudioSamples => { + let secs = t.0.div_euclid(TICKS_PER_SECOND); + let rem = Tick(t.0.rem_euclid(TICKS_PER_SECOND)).to_units_floor(sample_rate); + format!("{:02}:{:02}:{:02}:{rem:05}", secs / 3600, (secs / 60) % 60, secs % 60) + } + TimeDisplay::Seconds => format!("{:.3}", t.seconds()), + } +} + +/// Format a frame count as Feet + Frames (film footage counting). +pub fn format_feet_frames(frame: i64, per_foot: i64) -> String { + let pf = per_foot.max(1); + let sign = if frame < 0 { "-" } else { "" }; + let a = frame.unsigned_abs(); + let pf = pf as u64; + let w = if pf > 100 { 3 } else { 2 }; + format!("{sign}{:04}+{:0w$}", a / pf, a % pf) +} + +/// Error from timecode parsing. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct ParseError(pub String); + +impl fmt::Display for ParseError { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.write_str(&self.0) + } +} +impl std::error::Error for ParseError {} + +/// Parse user-typed timecode into a frame count. +/// +/// Supports standard formats (`HH:MM:SS:FF`, `HH;MM;SS;FF`), short entry (`1000` -> `00:00:10:00`), +/// and relative offsets (`+15`, `-1.00`). +/// +/// ### Application Compatibility Note +/// For oversized timecodes whose frame calculations exceed 64-bit limits, `craft-time` +/// saturates to `i64::MAX` (or `i64::MIN`) to keep calculations total without errors. +/// In contrast, EffectCraft returns `Err(ParseError("timecode too large"))`. +pub fn parse_timecode(input: &str, rate: FrameRate, drop_frame: bool, current: i64) -> Result { + let s = input.trim(); + if s.is_empty() { + return Err(ParseError("empty timecode".into())); + } + let (rel, body) = match s.as_bytes()[0] { + b'+' => (Some(1), &s[1..]), + b'-' => (Some(-1), &s[1..]), + _ => (None, s), + }; + let parts: Vec<&str> = body.split([':', ';', '.', ',']).collect(); + let nums: Vec = if parts.len() == 1 { + let digits = parts[0]; + if digits.is_empty() || !digits.bytes().all(|b| b.is_ascii_digit()) { + return Err(ParseError(format!("not a timecode: `{input}`"))); + } + if rel.is_some() { + vec![digits.parse().map_err(|_| ParseError("number too large".into()))?] + } else { + let mut v = Vec::new(); + let b = digits.as_bytes(); + let mut end = b.len(); + while end > 0 { + let start = end.saturating_sub(2); + v.push(digits[start..end].parse::().unwrap_or(0)); + end = start; + } + v.reverse(); + v + } + } else { + parts + .iter() + .map(|p| if p.is_empty() { Ok(0) } else { p.parse::().map_err(|_| ParseError(format!("bad field `{p}`"))) }) + .collect::>()? + }; + if nums.len() > 4 { + return Err(ParseError("too many timecode fields".into())); + } + let mut f4 = [0i64; 4]; + let off = 4 - nums.len(); + f4[off..].copy_from_slice(&nums); + let [h, m, sec, fr] = f4; + let base = rate.timecode_base(); + let frames = if nums.len() == 1 && rel.is_some() { + fr + } else if drop_frame && rate.supports_drop_frame() && m < 60 && sec < 60 && fr < base { + fields_to_frames(h, m, sec, fr, rate, true) + } else { + h.saturating_mul(3600).saturating_add(m.saturating_mul(60)).saturating_add(sec).saturating_mul(base).saturating_add(fr) + }; + Ok(match rel { + Some(sign) => current.saturating_add(frames.saturating_mul(sign)), + None => frames, + }) +} + +/// Parse a Feet + Frames field into a frame count. +pub fn parse_feet_frames(input: &str, per_foot: i64, current: i64) -> Result { + let s = input.trim(); + let bad = || ParseError(format!("not feet+frames: `{input}`")); + let num = |t: &str| -> Result { + let t = t.trim(); + if t.is_empty() { + return Ok(0); + } + if !t.bytes().all(|b| b.is_ascii_digit()) { + return Err(bad()); + } + t.parse::().map_err(|_| ParseError("number too large".into())) + }; + if s.is_empty() { + return Err(ParseError("empty feet+frames".into())); + } + let (sign, body) = match s.as_bytes()[0] { + b'-' => (-1, &s[1..]), + b'+' => (1, &s[1..]), + _ => (0, s), + }; + match body.rfind('+') { + Some(i) => { + let (feet, frames) = (num(&body[..i])?, num(&body[i + 1..])?); + let v = feet.checked_mul(per_foot.max(1)).and_then(|v| v.checked_add(frames)).ok_or_else(|| ParseError("number too large".into()))?; + Ok(if sign < 0 { -v } else { v }) + } + None => { + let v = num(body)?; + Ok(match sign { + 0 => v, + -1 => current.saturating_sub(v), + _ => current.saturating_add(v), + }) + } + } +} + +/// Common audio sample rates offered in sequence settings. +pub const SAMPLE_RATES: [i64; 6] = [32_000, 44_100, 48_000, 88_200, 96_000, 192_000]; + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_tick_conversions() { + assert_eq!(Tick::from_seconds_f64(1.0).0, TICKS_PER_SECOND); + assert_eq!(Tick::from_units(48000, 48000).0, TICKS_PER_SECOND); + assert_eq!(Tick(TICKS_PER_SECOND).to_units_floor(48000), 48000); + assert_eq!(Tick::from_rational(1, 1, 1).0, TICKS_PER_SECOND); + assert_eq!(Tick(TICKS_PER_SECOND).to_rational_floor(1, 1), 1); + assert_eq!(Tick(TICKS_PER_SECOND).to_rational_round(1, 1), 1); + } + + #[test] + fn test_hostile_rates_never_panic() { + let zero = FrameRate { num: 0, den: 0 }; + for r in [zero, FrameRate { num: 0, den: 1 }, FrameRate { num: 25, den: 0 }, FrameRate { num: -30, den: 1 }] { + assert_eq!(r.tick_of(10), FrameRate::default().tick_of(10)); + assert_eq!(r.frame_at(Tick(TICKS_PER_SECOND)), FrameRate::default().frame_at(Tick(TICKS_PER_SECOND))); + assert!(r.frame_duration() > Tick::ZERO); + assert!(r.timecode_base() >= 1); + let _ = r.snap_nearest(Tick(12345)); + let _ = format_time(Tick(TICKS_PER_SECOND), r, true, TimeDisplay::Timecode, 48_000); + } + } + + #[test] + fn test_broadcast_frame_rates() { + for r in FrameRate::COMMON { + assert_eq!(r.frame_at(r.tick_of(100)), 100); + assert_eq!(r.snap(r.tick_of(50)), r.tick_of(50)); + assert_eq!(r.snap_nearest(r.tick_of(50)), r.tick_of(50)); + } + } + + #[test] + fn test_smpte_timecode_roundtrip() { + for r in [FrameRate::FPS_24, FrameRate::FPS_25, FrameRate::FPS_30, FrameRate::FPS_29_97] { + for f in [0, 1, 24, 25, 30, 100, 1800, 54000] { + let df = r.supports_drop_frame(); + let tc = format_timecode_frames(f, r, df); + let parsed = parse_timecode(&tc, r, df, 0); + assert_eq!(parsed, Ok(f), "rate: {}, frame: {}, tc: {}", r, f, tc); + } + } + } + + #[test] + fn test_feet_frames_roundtrip() { + for pf in [16, 40] { + for f in [0, 1, 15, 16, 95, 199, 1234] { + let s = format_feet_frames(f, pf); + let parsed = parse_feet_frames(&s, pf, 0); + assert_eq!(parsed, Ok(f)); + } + } + } +} diff --git a/crates/time/tests/differential.rs b/crates/time/tests/differential.rs new file mode 100644 index 000000000..f4634c230 --- /dev/null +++ b/crates/time/tests/differential.rs @@ -0,0 +1,315 @@ +use craft_time::{ + FrameRate, SAMPLE_RATES, TICKS_PER_SECOND, Tick, TimeDisplay, TimeRange, format_feet_frames, format_time, format_timecode_ae, + format_timecode_frames, frames_to_fields, parse_feet_frames, parse_timecode, +}; +use proptest::prelude::*; + +#[test] +fn ticks_per_second_divides_all_standard_rates_evenly() { + assert_eq!(TICKS_PER_SECOND, 254_016_000_000); + + // Divisible by all standard frame rate numerators and denominators + for r in FrameRate::COMMON { + let den = r.den as i128; + let num = r.num as i128; + let product = TICKS_PER_SECOND as i128 * den; + assert_eq!(product % num, 0, "Rate {} ({} / {}) does not divide TICKS_PER_SECOND evenly", r.label(), r.num, r.den); + } + + // Audio sample rates + for &sr in &SAMPLE_RATES { + assert_eq!(TICKS_PER_SECOND % sr, 0, "Sample rate {sr} does not divide TICKS_PER_SECOND evenly"); + } +} + +#[test] +fn frame_roundtrip_all_common_rates() { + for r in FrameRate::COMMON { + for f in -10_000..=10_000 { + let t = r.tick_of(f); + let back = r.frame_at(t); + assert_eq!(back, f, "Roundtrip failed for {} at frame {}", r.label(), f); + assert_eq!(r.snap(t), t, "Snap of exact boundary should be identity"); + assert_eq!(r.snap_nearest(t), t, "Snap nearest of exact boundary should be identity"); + } + } +} + +#[test] +fn time_range_invariants() { + let r1 = TimeRange::new(Tick(100), Tick(50)); + assert_eq!(r1.end(), Tick(150)); + assert!(r1.contains(Tick(100))); + assert!(r1.contains(Tick(149))); + assert!(!r1.contains(Tick(150))); + + let r2 = TimeRange::new(Tick(120), Tick(40)); + assert!(r1.overlaps(&r2)); + let inter = r1.intersect(&r2); + assert_eq!(inter, Some(TimeRange::new(Tick(120), Tick(30)))); + + let empty = TimeRange::new(Tick(100), Tick(0)); + assert!(empty.is_empty()); +} + +#[test] +fn tick_checked_and_saturating_arithmetic_contract() { + // 1. Division overflow: i64::MIN / -1 + let min_tick = Tick(i64::MIN); + assert_eq!(min_tick.checked_div(-1), None, "checked_div must return None on i64::MIN / -1"); + assert_eq!(min_tick.saturating_div(-1), Some(Tick(i64::MAX)), "saturating_div must saturate to Tick(i64::MAX)"); + assert_eq!(min_tick / -1, Tick(i64::MAX), "Operator Div must saturate i64::MIN / -1 to Tick(i64::MAX) without panicking"); + assert_eq!(Tick(i64::MIN + 1) / -1, Tick(i64::MAX)); + assert_eq!(Tick(i64::MIN + 2) / -1, Tick(i64::MAX - 1)); + + // 2. Division by zero + let ten = Tick(10); + assert_eq!(ten.checked_div(0), None, "checked_div must return None on division by zero"); + assert_eq!(ten.saturating_div(0), None, "saturating_div must return None on division by zero"); + assert_eq!(ten / 0, Tick::ZERO, "Operator Div fallback returns Tick::ZERO on zero divisor"); + + // 3. Remainder overflow & zero divisor + assert_eq!(min_tick.checked_rem(Tick(-1)), None, "checked_rem must return None on i64::MIN % -1"); + assert_eq!(ten.checked_rem(Tick(0)), None, "checked_rem must return None on zero divisor"); + assert_eq!(min_tick % Tick(-1), Tick::ZERO, "Operator Rem on i64::MIN % -1 produces Tick::ZERO"); + assert_eq!(ten % Tick(0), Tick::ZERO, "Operator Rem on zero divisor produces Tick::ZERO"); + + // 4. Checked Add / Sub / Mul + assert_eq!(Tick(i64::MAX).checked_add(Tick(1)), None); + assert_eq!(Tick(i64::MIN).checked_sub(Tick(1)), None); + assert_eq!(Tick(i64::MAX).checked_mul(2), None); + assert_eq!(Tick(100).checked_add(Tick(50)), Some(Tick(150))); + assert_eq!(Tick(100).checked_sub(Tick(50)), Some(Tick(50))); + assert_eq!(Tick(100).checked_mul(5), Some(Tick(500))); + + // 5. Saturating Add / Sub / Mul + assert_eq!(Tick(i64::MAX).saturating_add(Tick(1)), Tick(i64::MAX)); + assert_eq!(Tick(i64::MIN).saturating_sub(Tick(1)), Tick(i64::MIN)); + assert_eq!(Tick(i64::MAX).saturating_mul(2), Tick(i64::MAX)); +} + +#[test] +fn hostile_rates_and_timecodes_never_panic() { + let zero = FrameRate { num: 0, den: 0 }; + for r in [ + zero, + FrameRate { num: 0, den: 1 }, + FrameRate { num: 25, den: 0 }, + FrameRate { num: -30, den: 1 }, + FrameRate::from_f64(0.0), + FrameRate::from_f64(f64::NAN), + ] { + assert_eq!(r.tick_of(10), FrameRate::default().tick_of(10)); + assert_eq!(r.frame_at(Tick(TICKS_PER_SECOND)), FrameRate::default().frame_at(Tick(TICKS_PER_SECOND))); + assert!(r.frame_duration() > Tick::ZERO); + assert!(r.timecode_base() >= 1); + let _ = r.snap_nearest(Tick(12345)); + let _ = format_time(Tick(TICKS_PER_SECOND), r, true, TimeDisplay::Timecode, 48_000); + } + assert_eq!(Tick::from_rational(100, 1, 0), Tick::ZERO); + assert_eq!(Tick::from_units(100, 0), Tick::ZERO); + assert_eq!(Tick(5).to_rational_floor(0, 1), 0); + assert_eq!(Tick(5).to_rational_round(0, 1), 0); + for t in [i64::MIN, -1, 0, 1, i64::MAX] { + for (n, d) in [(i64::MIN, 1), (-1, 1000), (1, i64::MAX), (i64::MAX, i64::MIN), (1, 0)] { + let _ = Tick(t).to_rational_round(n, d); + } + } + assert_eq!(Tick(5).mul_ratio(1, 0), Tick::ZERO); + assert_eq!(Tick(5).clamp(Tick(10), Tick(0)), Tick(0)); + let _ = format_timecode_frames(i64::MIN, FrameRate::FPS_29_97, true); + for tc in ["99999999999:99:99:99", "9223372036854775807", "+9223372036854775807", "99999999999;59;59;29"] { + let _ = parse_timecode(tc, FrameRate::FPS_29_97, true, i64::MAX); + let _ = parse_timecode(tc, FrameRate::FPS_25, false, i64::MIN); + } +} + +#[test] +fn boundary_rates_saturate_without_wrapping_and_keep_representable_values_exact() { + let slow = FrameRate { num: 1, den: i64::MAX }; + assert_eq!(slow.frame_duration(), Tick(i64::MAX)); + assert_eq!(slow.tick_of(i64::MAX), Tick(i64::MAX)); + assert_eq!(slow.tick_of(i64::MIN), Tick(i64::MIN)); + assert_eq!(slow.snap_nearest(Tick(i64::MAX)), Tick::ZERO, "compare the real boundary before saturating"); + assert_eq!(slow.snap_nearest(Tick(i64::MIN)), Tick::ZERO); + + let fast = FrameRate { num: i64::MAX, den: 1 }; + assert_eq!(fast.tick_of(i64::MAX), Tick(TICKS_PER_SECOND), "large factors can cancel exactly"); + assert_eq!(fast.tick_of(-i64::MAX), Tick(-TICKS_PER_SECOND)); + assert_eq!(fast.frame_at(Tick(TICKS_PER_SECOND)), i64::MAX); + assert_eq!(fast.frame_at(Tick(i64::MAX)), i64::MAX); + assert_eq!(fast.frame_at(Tick(i64::MIN)), i64::MIN); + assert_eq!(fast.timecode_base(), i64::MAX); + + for t in [i64::MIN, -TICKS_PER_SECOND, -1, 0, 1, TICKS_PER_SECOND, i64::MAX] { + assert_eq!(fast.snap(Tick(t)), Tick(t), "sub-tick frames include every integer tick"); + assert_eq!(fast.snap_nearest(Tick(t)), Tick(t)); + } + + let balanced = FrameRate { num: i64::MAX, den: i64::MAX }; + assert_eq!(balanced.frame_duration(), Tick(TICKS_PER_SECOND)); + assert_eq!(balanced.tick_of(2), Tick(2 * TICKS_PER_SECOND)); + assert_eq!(balanced.frame_at(Tick(2 * TICKS_PER_SECOND)), 2); + assert_eq!(balanced.timecode_base(), 1); + assert_eq!(FrameRate::new(i64::MIN, i64::MIN), FrameRate { num: -1, den: -1 }); + assert_eq!(FrameRate::new(i64::MIN, 0), FrameRate { num: -1, den: 0 }); + assert_eq!(FrameRate::new(i64::MIN, 1), FrameRate { num: i64::MIN, den: 1 }); +} + +#[test] +fn scalar_time_conversions_saturate_at_storage_limits() { + assert_eq!(Tick::from_units(i64::MAX, 1), Tick(i64::MAX)); + assert_eq!(Tick::from_units(i64::MIN, 1), Tick(i64::MIN)); + assert_eq!(Tick(i64::MAX).to_units_floor(i64::MAX), i64::MAX); + assert_eq!(Tick(i64::MIN).to_units_floor(i64::MAX), i64::MIN); + assert_eq!(Tick::from_rational(i64::MAX, i64::MAX, 1), Tick(i64::MAX)); + assert_eq!(Tick::from_rational(i64::MIN, i64::MAX, 1), Tick(i64::MIN)); + assert_eq!(Tick::from_rational(i64::MIN, i64::MAX, -1), Tick(i64::MAX)); + assert_eq!(Tick::from_rational(i64::MAX, i64::MAX, 0), Tick::ZERO); + assert_eq!(Tick(i64::MAX).to_rational_floor(1, i64::MAX), i64::MAX); + assert_eq!(Tick(i64::MIN).to_rational_round(1, i64::MAX), i64::MIN); + assert_eq!(Tick(i64::MAX).mul_ratio(i64::MAX, 1), Tick(i64::MAX)); + assert_eq!(Tick(i64::MIN).abs(), Tick(i64::MAX)); +} + +#[test] +fn smpte_drop_frame_exact_counts_and_negative_boundaries() { + let r = FrameRate::FPS_29_97; + assert!(r.supports_drop_frame()); + + // 10 minutes at 29.97: 10 * 60 * 30 - 9 * 2 = 17982 frames + let ten_min_frames = 17982; + let tc = format_timecode_frames(ten_min_frames, r, true); + assert_eq!(tc, "00;10;00;00"); + assert_eq!(parse_timecode(&tc, r, true, 0), Ok(ten_min_frames)); + + // 1 minute drop-frame boundary: frame 1800 is 00;01;00;02 + let one_min_frame = 1800; + let tc_1m = format_timecode_frames(one_min_frame, r, true); + assert_eq!(tc_1m, "00;01;00;02"); + assert_eq!(parse_timecode(&tc_1m, r, true, 0), Ok(one_min_frame)); + + // Negative drop-frame boundaries + let neg_1m = -1800; + let neg_tc = format_timecode_frames(neg_1m, r, true); + assert_eq!(neg_tc, "-00;01;00;02"); + assert_eq!(parse_timecode(&neg_tc, r, true, 0), Ok(neg_1m)); + + let neg_10m = -17982; + let neg_10m_tc = format_timecode_frames(neg_10m, r, true); + assert_eq!(neg_10m_tc, "-00;10;00;00"); + assert_eq!(parse_timecode(&neg_10m_tc, r, true, 0), Ok(neg_10m)); + + let neg_1h = -107892; + let neg_1h_tc = format_timecode_frames(neg_1h, r, true); + assert_eq!(neg_1h_tc, "-01;00;00;00"); + assert_eq!(parse_timecode(&neg_1h_tc, r, true, 0), Ok(neg_1h)); +} + +#[test] +fn effectcraft_ae_differential_formatting() { + let r30 = FrameRate::FPS_30; + let r_df = FrameRate::FPS_29_97; + + // EffectCraft / After Effects formats hours without zero-padding + assert_eq!(format_timecode_ae(75, r30, false), "0:00:02:15"); + assert_eq!(format_timecode_ae(75, r_df, true), "0;00;02;15"); + + // FilmCraft / Premiere formats hours with 2 zero-padded digits + assert_eq!(format_timecode_frames(75, r30, false), "00:00:02:15"); + assert_eq!(format_timecode_frames(75, r_df, true), "00;00;02;15"); + + // Negative frames in AE style + assert_eq!(format_timecode_ae(-75, r30, false), "-0:00:02:15"); + assert_eq!(format_timecode_ae(-1800, r_df, true), "-0;01;00;02"); + + // Multi-hour parity + assert_eq!(format_timecode_ae(3600 * 30 * 12, r30, false), "12:00:00:00"); + assert_eq!(format_timecode_frames(3600 * 30 * 12, r30, false), "12:00:00:00"); +} + +#[test] +fn feet_and_frames_film_standards() { + // 35mm: 16 frames per foot + assert_eq!(format_feet_frames(0, 16), "0000+00"); + assert_eq!(format_feet_frames(199, 16), "0012+07"); + assert_eq!(parse_feet_frames("12+07", 16, 0), Ok(199)); + assert_eq!(parse_feet_frames("0012+07", 16, 0), Ok(199)); + assert_eq!(format_feet_frames(-199, 16), "-0012+07"); + assert_eq!(parse_feet_frames("-0012+07", 16, 0), Ok(-199)); + + // 16mm: 40 frames per foot + assert_eq!(format_feet_frames(95, 40), "0002+15"); + assert_eq!(parse_feet_frames("2+15", 40, 0), Ok(95)); +} + +#[test] +fn exotic_frame_rate_durations() { + let r = FrameRate::from_f64(37.516); + assert_eq!(r.frame_duration().0, 6_770_871_094); + + // Standard rates must remain exact + assert_eq!(FrameRate::FPS_23_976.frame_duration().0, 10_594_584_000); + assert_eq!(FrameRate::FPS_24.frame_duration().0, 10_584_000_000); + assert_eq!(FrameRate::FPS_25.frame_duration().0, 10_160_640_000); + assert_eq!(FrameRate::FPS_29_97.frame_duration().0, 8_475_667_200); + assert_eq!(FrameRate::FPS_30.frame_duration().0, 8_467_200_000); + assert_eq!(FrameRate::FPS_60.frame_duration().0, 4_233_600_000); +} + +proptest! { + #[test] + fn prop_monotonic_frame_math( + frame in -100_000i64..100_000, + rate_idx in 0usize..FrameRate::COMMON.len(), + ) { + let r = FrameRate::COMMON[rate_idx]; + let t1 = r.tick_of(frame); + let t2 = r.tick_of(frame + 1); + prop_assert!(t1 < t2, "tick_of must be strictly increasing"); + prop_assert_eq!(r.frame_at(t1), frame); + prop_assert_eq!(r.frame_at(t2 - Tick(1)), frame); + } + + #[test] + fn prop_feet_frames_roundtrip( + frame in -50_000i64..50_000, + per_foot in 1i64..120, + ) { + let s = format_feet_frames(frame, per_foot); + let parsed = parse_feet_frames(&s, per_foot, 0); + prop_assert_eq!(parsed, Ok(frame)); + } + + #[test] + fn prop_drop_frame_bijection(f in 0i64..(24 * 107_892)) { + let r = FrameRate::FPS_29_97; + let (_, _h, m, s, fr) = frames_to_fields(f, r, true); + // dropped labels never appear + prop_assert!(!(s == 0 && fr < 2 && m % 10 != 0)); + let txt = format_timecode_frames(f, r, true); + prop_assert_eq!(parse_timecode(&txt, r, true, 0).unwrap(), f); + } + + #[test] + fn prop_ndf_parse_roundtrip(f in -500_000i64..500_000, ri in 0usize..11) { + let r = FrameRate::COMMON[ri]; + let txt = format_timecode_frames(f, r, false); + prop_assert_eq!(parse_timecode(&txt, r, false, 0).unwrap(), f); + } + + #[test] + fn prop_tick_div_monotonicity( + a in any::(), + b in any::(), + d in any::().prop_filter("non-zero", |&x| x != 0), + ) { + if a <= b { + if d > 0 { + prop_assert!((Tick(a) / d) <= (Tick(b) / d)); + } else { + prop_assert!((Tick(a) / d) >= (Tick(b) / d)); + } + } + } +} From 5416121e09a5f88763e027dac3aa33c56094b9a2 Mon Sep 17 00:00:00 2001 From: Zach Shallbetter Date: Tue, 20 Oct 2026 17:14:16 -0700 Subject: [PATCH 2/5] Address review findings in craft-time (fixes #15) - Clarify Tick(i64::MIN) overflow documentation in division/remainder contracts. - Format negative audio sample timestamps with absolute components and leading minus sign. - Parse 4-field negative timecodes as absolute positions rather than relative offsets. - Reject signed values inside multi-field timecode components. --- crates/time/src/lib.rs | 96 ++++++++++++++++++++++++++++++++++-------- 1 file changed, 78 insertions(+), 18 deletions(-) diff --git a/crates/time/src/lib.rs b/crates/time/src/lib.rs index 002eccb38..a3fbfc355 100644 --- a/crates/time/src/lib.rs +++ b/crates/time/src/lib.rs @@ -119,12 +119,12 @@ impl Tick { self.0.checked_mul(rhs).map(Tick) } - /// Checked division. Returns `None` if `rhs == 0` or if overflow occurred (`Tick::MIN / -1`). + /// Checked division. Returns `None` if `rhs == 0` or if overflow occurred (`Tick(i64::MIN) / -1`). pub fn checked_div(self, rhs: i64) -> Option { self.0.checked_div(rhs).map(Tick) } - /// Checked remainder. Returns `None` if `rhs.0 == 0` or if overflow occurred (`Tick::MIN % Tick(-1)`). + /// Checked remainder. Returns `None` if `rhs.0 == 0` or if overflow occurred (`Tick(i64::MIN) % Tick(-1)`). pub fn checked_rem(self, rhs: Tick) -> Option { self.0.checked_rem(rhs.0).map(Tick) } @@ -144,7 +144,7 @@ impl Tick { Tick(self.0.saturating_mul(rhs)) } - /// Saturating division. Returns `None` if `rhs == 0`, and saturates on overflow (`Tick::MIN / -1 -> Tick(i64::MAX)`). + /// Saturating division. Returns `None` if `rhs == 0`, and saturates on overflow (`Tick(i64::MIN) / -1 -> Tick(i64::MAX)`). pub fn saturating_div(self, rhs: i64) -> Option { if rhs == 0 { None } else { Some(Tick(self.0.saturating_div(rhs))) } } @@ -193,7 +193,7 @@ impl Mul for Tick { impl Div for Tick { type Output = Tick; fn div(self, rhs: i64) -> Tick { - // Operator division policy: Div saturates on overflow (e.g. Tick::MIN / -1 -> Tick(i64::MAX)) + // Operator division policy: Div saturates on overflow (e.g. Tick(i64::MIN) / -1 -> Tick(i64::MAX)) // using self.0.saturating_div(rhs) to preserve monotonicity across the full range. // Division by zero yields Tick::ZERO. For fallible division that detects zero divisors, // use Tick::checked_div(). @@ -205,7 +205,7 @@ impl Rem for Tick { type Output = Tick; fn rem(self, rhs: Tick) -> Tick { // Operator remainder policy: Rem produces exact remainder where defined, - // and Tick::ZERO for zero divisor or Tick::MIN % Tick(-1) (which is mathematically 0). + // and Tick::ZERO for zero divisor or Tick(i64::MIN) % Tick(-1) (which is mathematically 0). // For fallible remainder, use Tick::checked_rem(). if rhs.0 == 0 || (self.0 == i64::MIN && rhs.0 == -1) { Tick::ZERO } else { Tick(self.0 % rhs.0) } } @@ -512,9 +512,15 @@ pub fn format_time(t: Tick, rate: FrameRate, drop_frame: bool, display: TimeDisp format!("{}{}+{:02}", if neg { "-" } else { "" }, a / per_ft, a % per_ft) } TimeDisplay::AudioSamples => { - let secs = t.0.div_euclid(TICKS_PER_SECOND); - let rem = Tick(t.0.rem_euclid(TICKS_PER_SECOND)).to_units_floor(sample_rate); - format!("{:02}:{:02}:{:02}:{rem:05}", secs / 3600, (secs / 60) % 60, secs % 60) + let neg = t.0 < 0; + let abs_ticks = t.0.unsigned_abs(); + let total_secs = abs_ticks / (TICKS_PER_SECOND as u64); + let rem_ticks = abs_ticks % (TICKS_PER_SECOND as u64); + let rem_samples = (rem_ticks as u128 * sample_rate as u128) / (TICKS_PER_SECOND as u128); + let h = total_secs / 3600; + let m = (total_secs / 60) % 60; + let s = total_secs % 60; + format!("{}{:02}:{:02}:{:02}:{:05}", if neg { "-" } else { "" }, h, m, s, rem_samples) } TimeDisplay::Seconds => format!("{:.3}", t.seconds()), } @@ -555,9 +561,9 @@ pub fn parse_timecode(input: &str, rate: FrameRate, drop_frame: bool, current: i if s.is_empty() { return Err(ParseError("empty timecode".into())); } - let (rel, body) = match s.as_bytes()[0] { - b'+' => (Some(1), &s[1..]), - b'-' => (Some(-1), &s[1..]), + let (sign_char, body) = match s.as_bytes()[0] { + b'+' => (Some('+'), &s[1..]), + b'-' => (Some('-'), &s[1..]), _ => (None, s), }; let parts: Vec<&str> = body.split([':', ';', '.', ',']).collect(); @@ -566,7 +572,7 @@ pub fn parse_timecode(input: &str, rate: FrameRate, drop_frame: bool, current: i if digits.is_empty() || !digits.bytes().all(|b| b.is_ascii_digit()) { return Err(ParseError(format!("not a timecode: `{input}`"))); } - if rel.is_some() { + if sign_char.is_some() { vec![digits.parse().map_err(|_| ParseError("number too large".into()))?] } else { let mut v = Vec::new(); @@ -583,7 +589,16 @@ pub fn parse_timecode(input: &str, rate: FrameRate, drop_frame: bool, current: i } else { parts .iter() - .map(|p| if p.is_empty() { Ok(0) } else { p.parse::().map_err(|_| ParseError(format!("bad field `{p}`"))) }) + .map(|p| { + if p.is_empty() { + Ok(0) + } else { + if !p.bytes().all(|b| b.is_ascii_digit()) { + return Err(ParseError(format!("bad field `{p}`"))); + } + p.parse::().map_err(|_| ParseError(format!("bad field `{p}`"))) + } + }) .collect::>()? }; if nums.len() > 4 { @@ -594,17 +609,23 @@ pub fn parse_timecode(input: &str, rate: FrameRate, drop_frame: bool, current: i f4[off..].copy_from_slice(&nums); let [h, m, sec, fr] = f4; let base = rate.timecode_base(); - let frames = if nums.len() == 1 && rel.is_some() { + let frames = if nums.len() == 1 && sign_char.is_some() { fr } else if drop_frame && rate.supports_drop_frame() && m < 60 && sec < 60 && fr < base { fields_to_frames(h, m, sec, fr, rate, true) } else { h.saturating_mul(3600).saturating_add(m.saturating_mul(60)).saturating_add(sec).saturating_mul(base).saturating_add(fr) }; - Ok(match rel { - Some(sign) => current.saturating_add(frames.saturating_mul(sign)), - None => frames, - }) + + match sign_char { + Some('+') => Ok(current.saturating_add(frames)), + Some('-') => { + // A full 4-field timecode with leading '-' represents an absolute negative timecode (e.g. preroll). + // Single-number or partial relative offsets (e.g. -15 or -1.00) offset from current. + if nums.len() == 4 { Ok(-frames) } else { Ok(current.saturating_sub(frames)) } + } + None | Some(_) => Ok(frames), + } } /// Parse a Feet + Frames field into a frame count. @@ -697,6 +718,45 @@ mod tests { } } + #[test] + fn test_audio_samples_formatting_negative_ticks() { + let r = FrameRate::FPS_24; + let neg_1s = Tick(-TICKS_PER_SECOND); + assert_eq!(format_time(neg_1s, r, false, TimeDisplay::AudioSamples, 48_000), "-00:00:01:00000"); + + let pos_1s = Tick(TICKS_PER_SECOND); + assert_eq!(format_time(pos_1s, r, false, TimeDisplay::AudioSamples, 48_000), "00:00:01:00000"); + + let neg_complex = Tick(-3661 * TICKS_PER_SECOND); + assert_eq!(format_time(neg_complex, r, false, TimeDisplay::AudioSamples, 48_000), "-01:01:01:00000"); + + let zero = Tick::ZERO; + assert_eq!(format_time(zero, r, false, TimeDisplay::AudioSamples, 48_000), "00:00:00:00000"); + } + + #[test] + fn test_parse_timecode_negative_absolute_independent_of_current() { + let r = FrameRate::FPS_29_97; + // Negative full 4-field timecode must parse as absolute negative frames, not offset from current + assert_eq!(parse_timecode("-00;01;00;02", r, true, 5000), Ok(-1800)); + assert_eq!(parse_timecode("-00;01;00;02", r, true, 0), Ok(-1800)); + + let r24 = FrameRate::FPS_24; + assert_eq!(parse_timecode("-01:00:00:00", r24, false, 9999), Ok(-86400)); + + // Single-number relative entries must offset from current + assert_eq!(parse_timecode("-15", r24, false, 5000), Ok(4985)); + assert_eq!(parse_timecode("+15", r24, false, 5000), Ok(5015)); + } + + #[test] + fn test_parse_timecode_rejects_signed_inner_fields() { + let r = FrameRate::FPS_24; + assert!(parse_timecode("00:-30:00:00", r, false, 0).is_err()); + assert!(parse_timecode("01:+5:00:00", r, false, 0).is_err()); + assert!(parse_timecode("00:00:-01:00", r, false, 0).is_err()); + } + #[test] fn test_feet_frames_roundtrip() { for pf in [16, 40] { From ae0f0488dc7a97653a26efe69991a4b2588463ac Mon Sep 17 00:00:00 2001 From: "Dylan M. Taylor" Date: Fri, 13 Nov 2026 03:44:51 +0000 Subject: [PATCH 3/5] craft-time: preserve SoundCraft time API and serialization --- Cargo.lock | 2 + crates/time/Cargo.toml | 2 + crates/time/src/lib.rs | 4 + crates/time/src/soundcraft/format.rs | 241 ++++++++ crates/time/src/soundcraft/grid.rs | 809 +++++++++++++++++++++++++ crates/time/src/soundcraft/mod.rs | 155 +++++ crates/time/src/soundcraft/tempo.rs | 622 +++++++++++++++++++ crates/time/src/soundcraft/timecode.rs | 309 ++++++++++ 8 files changed, 2144 insertions(+) create mode 100644 crates/time/src/soundcraft/format.rs create mode 100644 crates/time/src/soundcraft/grid.rs create mode 100644 crates/time/src/soundcraft/mod.rs create mode 100644 crates/time/src/soundcraft/tempo.rs create mode 100644 crates/time/src/soundcraft/timecode.rs diff --git a/Cargo.lock b/Cargo.lock index 89478f3cc..4136a81de 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -717,6 +717,8 @@ version = "0.1.0" dependencies = [ "proptest", "serde", + "serde_json", + "thiserror 2.0.21", ] [[package]] diff --git a/crates/time/Cargo.toml b/crates/time/Cargo.toml index 0f63cb228..5ef614bdc 100644 --- a/crates/time/Cargo.toml +++ b/crates/time/Cargo.toml @@ -12,6 +12,8 @@ workspace = true [dependencies] serde = { workspace = true } +thiserror.workspace = true [dev-dependencies] +serde_json = { workspace = true } proptest = "1.4" diff --git a/crates/time/src/lib.rs b/crates/time/src/lib.rs index a3fbfc355..0d3703060 100644 --- a/crates/time/src/lib.rs +++ b/crates/time/src/lib.rs @@ -15,6 +15,10 @@ use serde::{Deserialize, Serialize}; use std::fmt; use std::ops::{Add, AddAssign, Div, Mul, Neg, Rem, Sub, SubAssign}; +/// SoundCraft's sample-index, tempo-map, grid, and display-time API, retained verbatim for +/// session-format compatibility while sharing the broader Craft time primitives above. +pub mod soundcraft; + /// Clock ticks per second: 254 016 000 000. pub const TICKS_PER_SECOND: i64 = 254_016_000_000; diff --git a/crates/time/src/soundcraft/format.rs b/crates/time/src/soundcraft/format.rs new file mode 100644 index 000000000..11b588c3f --- /dev/null +++ b/crates/time/src/soundcraft/format.rs @@ -0,0 +1,241 @@ +//! Formatting and parsing positions in the five timebases. + +use super::{BarBeat, FrameRate, SampleRate, Samples, TempoMap, TimeError, Timecode, timecode::feet_frames}; + +/// The counters' and rulers' timebases. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Default, serde::Serialize, serde::Deserialize)] +pub enum TimeFormat { + BarsBeats, + #[default] + MinSecs, + Timecode, + FeetFrames, + Samples, +} + +impl TimeFormat { + pub const ALL: [TimeFormat; 5] = [TimeFormat::BarsBeats, TimeFormat::MinSecs, TimeFormat::Timecode, TimeFormat::FeetFrames, TimeFormat::Samples]; + + pub fn label(self) -> &'static str { + match self { + TimeFormat::BarsBeats => "Bars|Beats", + TimeFormat::MinSecs => "Min:Secs", + TimeFormat::Timecode => "Timecode", + TimeFormat::FeetFrames => "Feet+Frames", + TimeFormat::Samples => "Samples", + } + } + + pub fn id(self) -> &'static str { + match self { + TimeFormat::BarsBeats => "bars_beats", + TimeFormat::MinSecs => "min_secs", + TimeFormat::Timecode => "timecode", + TimeFormat::FeetFrames => "feet_frames", + TimeFormat::Samples => "samples", + } + } + + pub fn from_id(id: &str) -> Option { + TimeFormat::ALL.into_iter().find(|f| f.id() == id || f.label().eq_ignore_ascii_case(id)) + } +} + +fn min_secs(s: Samples, sr: SampleRate) -> String { + let neg = s < 0; + // Integer maths: a float `seconds * 1000` lands just below exact millisecond positions. + let ms_total = i64::try_from(i128::from(s).abs() * 1000 / i128::from(sr.hz().max(1))).unwrap_or(i64::MAX); + let mins = ms_total / 60_000; + let secs = (ms_total / 1000) % 60; + let ms = ms_total % 1000; + format!("{}{mins}:{secs:02}.{ms:03}", if neg { "-" } else { "" }) +} + +/// Format an absolute position. `tc_start` is the timecode of sample 0 in samples, `ff_start` the +/// feet+frames reading of sample 0 in frames. +pub fn format_position(s: Samples, fmt: TimeFormat, sr: SampleRate, map: &TempoMap, rate: FrameRate, tc_start: Samples, ff_start: i64) -> String { + match fmt { + TimeFormat::Samples => s.to_string(), + TimeFormat::MinSecs => min_secs(s, sr), + TimeFormat::Timecode => Timecode::from_samples(s.saturating_add(tc_start), rate, sr).format(rate), + TimeFormat::FeetFrames => { + let (ft, fr) = feet_frames(s, rate, sr, ff_start); + format!("{ft}+{fr:02}") + } + TimeFormat::BarsBeats => { + let bb = map.bar_beat_at(s, sr); + format!("{}| {}| {:03}", bb.bar, bb.beat, bb.tick) + } + } +} + +/// Format a length starting at `start` (Bars|Beats lengths count from 0|0|000). +pub fn format_length(start: Samples, len: Samples, fmt: TimeFormat, sr: SampleRate, map: &TempoMap, rate: FrameRate) -> String { + match fmt { + TimeFormat::BarsBeats => { + let t0 = map.samples_to_ticks(start, sr); + let t1 = map.samples_to_ticks(start.saturating_add(len), sr); + let m = map.meter_at_tick(t0); + let d = (t1 - t0).max(0); + let bars = d / m.ticks_per_bar().max(1); + let rem = d % m.ticks_per_bar().max(1); + format!("{bars}| {}| {:03}", rem / m.ticks_per_beat().max(1), rem % m.ticks_per_beat().max(1)) + } + TimeFormat::Timecode => Timecode::from_samples(len, rate, sr).format(rate), + other => format_position(len, other, sr, map, rate, 0, 0), + } +} + +/// Parse a position typed into a counter or dialog. +pub fn parse_position( + text: &str, + fmt: TimeFormat, + sr: SampleRate, + map: &TempoMap, + rate: FrameRate, + tc_start: Samples, + ff_start: i64, +) -> Result { + let t = text.trim(); + let err = || TimeError::Parse(t.to_string(), fmt.label()); + match fmt { + TimeFormat::Samples => t.replace([',', '_'], "").parse::().map_err(|_| err()), + TimeFormat::MinSecs => { + let (neg, body) = match t.strip_prefix('-') { + Some(r) => (true, r), + None => (false, t), + }; + let (m, s) = match body.split_once(':') { + Some((m, s)) => (m.trim().parse::().map_err(|_| err())?, s.trim().parse::().map_err(|_| err())?), + None => (0.0, body.parse::().map_err(|_| err())?), + }; + if !m.is_finite() || !s.is_finite() || m < 0.0 || s < 0.0 { + return Err(err()); + } + let v = sr.samples(m * 60.0 + s); + Ok(if neg { -v } else { v }) + } + TimeFormat::Timecode => { + let tc = Timecode::parse(t)?; + if tc.minutes >= 60 || tc.seconds >= 60 || tc.frames >= rate.nominal().max(1) { + return Err(err()); + } + if rate.is_drop() && tc.seconds == 0 && tc.minutes % 10 != 0 { + let dropped = if rate.nominal() >= 60 { 4 } else { 2 }; + if tc.frames < dropped { + return Err(err()); + } + } + Ok(tc.to_samples(rate, sr).saturating_sub(tc_start)) + } + TimeFormat::FeetFrames => { + let (ft, fr) = t.split_once('+').ok_or_else(err)?; + let ft: i64 = ft.trim().parse().map_err(|_| err())?; + let fr: i64 = fr.trim().parse().map_err(|_| err())?; + Ok(rate.samples_at_frame(ft.saturating_mul(16).saturating_add(fr).saturating_sub(ff_start), sr)) + } + TimeFormat::BarsBeats => { + let parts: Vec = + t.split(['|', '.', ' ']).filter(|p| !p.is_empty()).map(str::parse::).collect::>().map_err(|_| err())?; + let bb = BarBeat { + bar: *parts.first().ok_or_else(err)?, + beat: parts.get(1).copied().unwrap_or(1).max(1), + tick: parts.get(2).copied().unwrap_or(0).max(0), + }; + Ok(map.samples_at_bar_beat(bb, sr)) + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn ctx() -> (SampleRate, TempoMap, FrameRate) { + (SampleRate::HZ_48000, TempoMap::default(), FrameRate::Fps30) + } + + #[test] + fn huge_typed_positions_parse_without_overflowing() { + let (sr, map, rate) = ctx(); + for (text, fmt) in [ + ("999999999999999999:0", TimeFormat::Timecode), + ("100000000000000:00:00:00", TimeFormat::Timecode), + ("-9223372036854775808", TimeFormat::BarsBeats), + ] { + let _ = parse_position(text, fmt, sr, &map, rate, 0, 0); + } + } + + #[test] + fn formats_like_pro_tools_counters() { + let (sr, map, rate) = ctx(); + assert_eq!(format_position(0, TimeFormat::MinSecs, sr, &map, rate, 0, 0), "0:00.000"); + assert_eq!(format_position(48_000 * 75 + 24_000, TimeFormat::MinSecs, sr, &map, rate, 0, 0), "1:15.500"); + assert_eq!(format_position(0, TimeFormat::BarsBeats, sr, &map, rate, 0, 0), "1| 1| 000"); + assert_eq!(format_position(48_000, TimeFormat::Timecode, sr, &map, rate, 0, 0), "00:00:01:00"); + assert_eq!(format_position(12345, TimeFormat::Samples, sr, &map, rate, 0, 0), "12345"); + } + + #[test] + fn minimum_bar_parses_without_overflow() { + let (sr, map, rate) = ctx(); + let p = |t: &str| parse_position(t, TimeFormat::BarsBeats, sr, &map, rate, 0, 0); + assert!(p("-9223372036854775808|01|000").is_ok_and(|s| s < 0)); + assert_eq!(p("2|3|000").ok(), Some(144_000)); + assert_eq!(p("0|1|000").ok(), Some(-96_000)); + } + + #[test] + fn parse_round_trips() { + let (sr, map, rate) = ctx(); + for fmt in TimeFormat::ALL { + for s in [0i64, 48_000, 96_000 * 3, 1_440_000] { + let text = format_position(s, fmt, sr, &map, rate, 0, 0); + let back = parse_position(&text, fmt, sr, &map, rate, 0, 0).unwrap(); + assert!((back - s).abs() <= 48, "{fmt:?} {s} → {text} → {back}"); + } + } + } + + #[test] + fn parse_round_trips_timecode_negative() { + let (sr, map, _) = ctx(); + let rate = FrameRate::Fps25; + for fmt in [TimeFormat::Timecode, TimeFormat::FeetFrames] { + for s in [-48_000i64, -96_000 * 3, -1_440_000] { + let text = format_position(s, fmt, sr, &map, rate, 0, 0); + let back = parse_position(&text, fmt, sr, &map, rate, 0, 0).unwrap(); + assert_eq!(back, s, "{fmt:?} {s} → {text} → {back}"); + } + } + } + + #[test] + fn parse_rejects_garbage() { + let (sr, map, rate) = ctx(); + assert!(parse_position("abc", TimeFormat::Samples, sr, &map, rate, 0, 0).is_err()); + assert!(parse_position("1:x", TimeFormat::MinSecs, sr, &map, rate, 0, 0).is_err()); + assert!(parse_position("", TimeFormat::BarsBeats, sr, &map, rate, 0, 0).is_err()); + } + + #[test] + fn drop_frame_skipped_labels_are_rejected() { + let (sr, map, _) = ctx(); + for (rate, skipped) in [ + (FrameRate::Fps29_97Drop, &["00:01:00;00", "00:01:00;01"][..]), + (FrameRate::Fps59_94Drop, &["00:01:00;00", "00:01:00;01", "00:01:00;02", "00:01:00;03"][..]), + ] { + for text in skipped { + assert!(parse_position(text, TimeFormat::Timecode, sr, &map, rate, 0, 0).is_err(), "accepted skipped label {text} at {rate:?}"); + } + assert!(parse_position("00:10:00;00", TimeFormat::Timecode, sr, &map, rate, 0, 0).is_ok()); + } + } + + #[test] + fn bar_lengths() { + let (sr, map, rate) = ctx(); + assert_eq!(format_length(0, 96_000 + 24_000, TimeFormat::BarsBeats, sr, &map, rate), "1| 1| 000"); + } +} diff --git a/crates/time/src/soundcraft/grid.rs b/crates/time/src/soundcraft/grid.rs new file mode 100644 index 000000000..5f32ca6d8 --- /dev/null +++ b/crates/time/src/soundcraft/grid.rs @@ -0,0 +1,809 @@ +//! Grid and Nudge values: musical or absolute step sizes, grid lines and snapping. + +use super::{FrameRate, SampleRate, Samples, TICKS_PER_QUARTER, TempoMap, Timecode, to_samples}; + +/// Note values for Bars|Beats grids. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, serde::Serialize, serde::Deserialize)] +pub enum NoteValue { + Bar, + Whole, + Half, + Quarter, + Eighth, + Sixteenth, + ThirtySecond, + SixtyFourth, +} + +impl NoteValue { + /// Every musical increment in menu order, from largest to smallest. + pub const ALL: [NoteValue; 8] = [ + NoteValue::Bar, + NoteValue::Whole, + NoteValue::Half, + NoteValue::Quarter, + NoteValue::Eighth, + NoteValue::Sixteenth, + NoteValue::ThirtySecond, + NoteValue::SixtyFourth, + ]; + /// Returns the nominal tick length; bars are adjusted for the active meter when used. + pub fn ticks(self) -> i64 { + match self { + NoteValue::Bar => TICKS_PER_QUARTER * 4, + NoteValue::Whole => TICKS_PER_QUARTER * 4, + NoteValue::Half => TICKS_PER_QUARTER * 2, + NoteValue::Quarter => TICKS_PER_QUARTER, + NoteValue::Eighth => TICKS_PER_QUARTER / 2, + NoteValue::Sixteenth => TICKS_PER_QUARTER / 4, + NoteValue::ThirtySecond => TICKS_PER_QUARTER / 8, + NoteValue::SixtyFourth => TICKS_PER_QUARTER / 16, + } + } + /// Returns the menu label for this musical increment. + pub fn label(self) -> &'static str { + match self { + NoteValue::Bar => "1 bar", + NoteValue::Whole => "Whole note", + NoteValue::Half => "1/2 note", + NoteValue::Quarter => "1/4 note", + NoteValue::Eighth => "1/8 note", + NoteValue::Sixteenth => "1/16 note", + NoteValue::ThirtySecond => "1/32 note", + NoteValue::SixtyFourth => "1/64 note", + } + } +} + +/// The time scale used by a Grid or Nudge value. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, serde::Serialize, serde::Deserialize)] +pub enum GridScale { + BarsBeats, + MinSecs, + Timecode, + FeetFrames, + Samples, + ClipsMarkers, +} + +impl GridScale { + /// Numeric time scales shared by Grid and Nudge. + pub const TIME: [GridScale; 5] = [GridScale::BarsBeats, GridScale::MinSecs, GridScale::Timecode, GridScale::FeetFrames, GridScale::Samples]; + + /// Returns the user-facing scale label. + pub fn label(self) -> &'static str { + match self { + GridScale::BarsBeats => "Bars|Beats", + GridScale::MinSecs => "Min:Secs", + GridScale::Timecode => "Timecode", + GridScale::FeetFrames => "Feet+Frames", + GridScale::Samples => "Samples", + GridScale::ClipsMarkers => "Clips/Markers", + } + } + + /// Returns the stable command and session identifier. + pub fn id(self) -> &'static str { + match self { + GridScale::BarsBeats => "bars_beats", + GridScale::MinSecs => "min_secs", + GridScale::Timecode => "timecode", + GridScale::FeetFrames => "feet_frames", + GridScale::Samples => "samples", + GridScale::ClipsMarkers => "clips_markers", + } + } + + /// Parses a stable identifier or user-facing label. + pub fn from_id(id: &str) -> Option { + Self::TIME.into_iter().chain([GridScale::ClipsMarkers]).find(|scale| scale.id() == id || scale.label().eq_ignore_ascii_case(id)) + } +} + +/// Timecode Grid/Nudge increments, ordered independently of the session frame rate. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, serde::Serialize, serde::Deserialize)] +pub enum TimecodeValue { + Second, + /// Five physical frames, independent of skipped drop-frame labels. + FiveFrames, + Frame, + HalfFrame, + QuarterFrame, + SubFrame, +} + +impl TimecodeValue { + /// Every Timecode increment in menu order, from largest to smallest. + pub const ALL: [TimecodeValue; 6] = [ + TimecodeValue::Second, + TimecodeValue::FiveFrames, + TimecodeValue::Frame, + TimecodeValue::HalfFrame, + TimecodeValue::QuarterFrame, + TimecodeValue::SubFrame, + ]; + + /// Returns the user-facing increment label. + pub fn label(self) -> &'static str { + match self { + TimecodeValue::Second => "1 second", + TimecodeValue::FiveFrames => "5 frames", + TimecodeValue::Frame => "1 frame", + TimecodeValue::HalfFrame => "1/2 frame", + TimecodeValue::QuarterFrame => "1/4 frame", + TimecodeValue::SubFrame => "1 subframe", + } + } + + /// Returns the stable command and session identifier. + pub fn id(self) -> &'static str { + match self { + TimecodeValue::Second => "second", + TimecodeValue::FiveFrames => "5_frames", + TimecodeValue::Frame => "frame", + TimecodeValue::HalfFrame => "half_frame", + TimecodeValue::QuarterFrame => "quarter_frame", + TimecodeValue::SubFrame => "subframe", + } + } + + /// Parses a stable identifier or user-facing label. + pub fn from_id(id: &str) -> Option { + Self::ALL.into_iter().find(|value| value.id() == id || value.label().eq_ignore_ascii_case(id)) + } + + /// Returns the increment length in frames at the session rate. + fn frames(self, rate: FrameRate) -> f64 { + match self { + TimecodeValue::Second => rate.nominal() as f64, + TimecodeValue::FiveFrames => 5.0, + TimecodeValue::Frame => 1.0, + TimecodeValue::HalfFrame => 0.5, + TimecodeValue::QuarterFrame => 0.25, + TimecodeValue::SubFrame => 0.01, + } + } +} + +/// Feet+Frames Grid/Nudge increments in physical frames, independent of drop-frame label skips. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, serde::Serialize, serde::Deserialize)] +pub enum FeetFramesValue { + Foot, + Frame, + QuarterFrame, + SubFrame, +} + +impl FeetFramesValue { + /// Every Feet+Frames increment in menu order, from largest to smallest. + pub const ALL: [FeetFramesValue; 4] = [FeetFramesValue::Foot, FeetFramesValue::Frame, FeetFramesValue::QuarterFrame, FeetFramesValue::SubFrame]; + + /// Returns the user-facing increment label. + pub fn label(self) -> &'static str { + match self { + FeetFramesValue::Foot => "1 foot", + FeetFramesValue::Frame => "1 frame", + FeetFramesValue::QuarterFrame => "1/4 frame", + FeetFramesValue::SubFrame => "1 subframe", + } + } + + /// Returns the stable command and session identifier. + pub fn id(self) -> &'static str { + match self { + FeetFramesValue::Foot => "foot", + FeetFramesValue::Frame => "frame", + FeetFramesValue::QuarterFrame => "quarter_frame", + FeetFramesValue::SubFrame => "subframe", + } + } + + /// Parses a stable identifier or user-facing label. + pub fn from_id(id: &str) -> Option { + Self::ALL.into_iter().find(|value| value.id() == id || value.label().eq_ignore_ascii_case(id)) + } + + /// Returns the increment length in frames. + fn frames(self) -> f64 { + let (num, den) = self.frame_ratio(); + num as f64 / den as f64 + } + + /// Exact numerator and denominator of the increment in physical frames. + fn frame_ratio(self) -> (i128, i128) { + match self { + Self::Foot => (16, 1), + Self::Frame => (1, 1), + Self::QuarterFrame => (1, 4), + Self::SubFrame => (1, 100), + } + } + + /// Apply the counter origin in frames before rounding once to the nearest sample. + fn sample_line(self, index: i128, sr: SampleRate, rate: FrameRate, origin: i64) -> Samples { + let (num, den) = self.frame_ratio(); + let (rate_num, rate_den) = rate.rational(); + let frames = index * num - i128::from(origin) * den; + let samples = frames * i128::from(sr.hz().max(1)) * i128::from(rate_den); + let divisor = den * i128::from(rate_num); + let magnitude = (samples.abs() + divisor / 2) / divisor; + let rounded = if samples < 0 { -magnitude } else { magnitude }; + i64::try_from(rounded).unwrap_or(if rounded < 0 { i64::MIN } else { i64::MAX }) + } + + /// Find a line index using exact frame arithmetic, allowing for whole-sample rounding. + fn line_index(self, position: Samples, sr: SampleRate, rate: FrameRate, origin: i64) -> i128 { + let (num, den) = self.frame_ratio(); + let (rate_num, rate_den) = rate.rational(); + let scale = i128::from(sr.hz().max(1)) * i128::from(rate_den); + let frames = i128::from(position) * i128::from(rate_num) + i128::from(origin) * scale; + let mut index = (frames * den).div_euclid(scale * num); + if self.sample_line(index + 1, sr, rate, origin) <= position { + index += 1; + } + index + } + + /// The Feet+Frames line at or before a sample, relative to the counter's frame origin. + pub fn line_at_or_before(self, position: Samples, sr: SampleRate, rate: FrameRate, origin: i64) -> Samples { + self.sample_line(self.line_index(position, sr, rate, origin), sr, rate, origin) + } + + /// The next distinct sample line; subframe increments may share a sample at low sample rates. + fn next_line(self, position: Samples, sr: SampleRate, rate: FrameRate, origin: i64) -> Samples { + self.sample_line(self.line_index(position, sr, rate, origin) + 1, sr, rate, origin).max(position.saturating_add(1)) + } + + /// Snap against frame-origin lines, choosing the earlier line when distances are equal. + pub fn snap(self, position: Samples, sr: SampleRate, rate: FrameRate, origin: i64) -> Samples { + let lo = self.line_at_or_before(position, sr, rate, origin); + let hi = self.next_line(lo, sr, rate, origin); + if i128::from(position) - i128::from(lo) <= i128::from(hi) - i128::from(position) { lo } else { hi } + } + + /// Distinct Feet+Frames sample lines within `[start, end)`, capped at `max`. + pub fn lines(self, start: Samples, end: Samples, sr: SampleRate, rate: FrameRate, origin: i64, max: usize) -> Vec { + let mut out = Vec::new(); + let mut line = self.line_at_or_before(start, sr, rate, origin); + while line < end && out.len() < max { + if line >= start { + out.push(line); + } + let next = self.next_line(line, sr, rate, origin); + if next <= line { + break; + } + line = next; + } + out + } +} + +/// A grid or nudge step. +#[derive(Debug, Clone, Copy, PartialEq, serde::Serialize, serde::Deserialize)] +pub enum GridValue { + /// Musical: note value, optionally dotted or triplet. + Note { value: NoteValue, dotted: bool, triplet: bool }, + /// Absolute seconds (Min:Secs grids: 0.001 … 1 s). + Seconds(f64), + /// Frames at the session timecode rate. + /// Kept for sessions and commands written before named timecode increments were added. + Frames(i64), + /// A named Timecode increment, including fractional frames and one second. + Timecode(TimecodeValue), + /// A Feet+Frames increment (one foot is 16 frames). + FeetFrames(FeetFramesValue), + /// Raw samples. + Samples(i64), +} + +impl Default for GridValue { + /// Starts on a straight sixteenth-note Bars|Beats grid. + fn default() -> Self { + GridValue::Note { value: NoteValue::Sixteenth, dotted: false, triplet: false } + } +} + +impl GridValue { + /// Returns the user-facing label for this increment. + pub fn label(&self) -> String { + match self { + GridValue::Note { value, dotted, triplet } => { + let mut s = value.label().to_string(); + if *dotted { + s.push_str(" dotted"); + } + if *triplet { + s.push_str(" triplet"); + } + s + } + GridValue::Seconds(s) => { + if *s >= 1.0 { + format!("{s} second{}", if *s == 1.0 { "" } else { "s" }) + } else { + format!("{} ms", (s * 1000.0).round() as i64) + } + } + GridValue::Frames(f) => format!("{f} frame{}", if *f == 1 { "" } else { "s" }), + GridValue::Timecode(value) => value.label().to_string(), + GridValue::FeetFrames(value) => value.label().to_string(), + GridValue::Samples(n) => format!("{n} sample{}", if *n == 1 { "" } else { "s" }), + } + } + + /// Returns the time scale to which this increment belongs. + pub fn scale(self) -> GridScale { + match self { + GridValue::Note { .. } => GridScale::BarsBeats, + GridValue::Seconds(_) => GridScale::MinSecs, + GridValue::Frames(_) | GridValue::Timecode(_) => GridScale::Timecode, + GridValue::FeetFrames(_) => GridScale::FeetFrames, + GridValue::Samples(_) => GridScale::Samples, + } + } + + /// Returns the default increment for a newly selected time scale. + pub fn for_scale(scale: GridScale) -> GridValue { + match scale { + GridScale::BarsBeats => GridValue::default(), + GridScale::MinSecs => GridValue::Seconds(1.0), + GridScale::Timecode => GridValue::Timecode(TimecodeValue::Frame), + GridScale::FeetFrames => GridValue::FeetFrames(FeetFramesValue::Frame), + GridScale::Samples => GridValue::Samples(100), + GridScale::ClipsMarkers => GridValue::default(), + } + } + + /// The next larger or smaller menu value of the same kind, or `self` at either end. Note values keep + /// their dot or triplet modifier. + pub fn stepped(self, larger: bool) -> GridValue { + /// Finds the next value in a numeric increment ladder without wrapping. + fn next(ladder: &[T], v: T, larger: bool) -> Option { + if larger { ladder.iter().copied().find(|x| *x > v) } else { ladder.iter().rev().copied().find(|x| *x < v) } + } + match self { + GridValue::Note { value, dotted, triplet } => { + let i = NoteValue::ALL.iter().position(|v| *v == value).unwrap_or(0); + let j = if larger { i.checked_sub(1) } else { i.checked_add(1) }; + j.and_then(|j| NoteValue::ALL.get(j)).map_or(self, |&value| GridValue::Note { value, dotted, triplet }) + } + GridValue::Seconds(s) => next(&[0.001, 0.01, 0.1, 0.5, 1.0], s, larger).map_or(self, GridValue::Seconds), + GridValue::Frames(f) => next(&[1, 10, 100], f, larger).map_or(self, GridValue::Frames), + GridValue::Timecode(value) => step_enum(&TimecodeValue::ALL, value, larger).map_or(self, GridValue::Timecode), + GridValue::FeetFrames(value) => step_enum(&FeetFramesValue::ALL, value, larger).map_or(self, GridValue::FeetFrames), + GridValue::Samples(n) => next(&[1, 2, 10, 100, 1_000, 10_000], n, larger).map_or(self, GridValue::Samples), + } + } + + fn note_ticks(value: NoteValue, dotted: bool, triplet: bool, map: &TempoMap, at_tick: i64) -> i64 { + let base = if value == NoteValue::Bar { map.meter_at_tick(at_tick).ticks_per_bar() } else { value.ticks() }; + let mut t = base; + if dotted { + t = t * 3 / 2; + } + if triplet { + t = t * 2 / 3; + } + t.max(1) + } + + /// The step in samples at position `at` (musical steps depend on tempo). + pub fn step_samples(&self, at: Samples, sr: SampleRate, map: &TempoMap, rate: FrameRate) -> Samples { + match *self { + GridValue::Note { value, dotted, triplet } => { + let t0 = map.samples_to_ticks(at, sr); + let dt = Self::note_ticks(value, dotted, triplet, map, t0); + (map.tick_to_samples(t0.saturating_add(dt), sr).saturating_sub(map.tick_to_samples(t0, sr))).max(1) + } + GridValue::Seconds(s) => sr.samples(s.clamp(0.000_01, 3600.0)).max(1), + GridValue::Frames(f) => to_samples(rate.samples_per_frame(sr) * f.clamp(1, 1_000_000) as f64).max(1), + GridValue::Timecode(value) => to_samples(rate.samples_per_frame(sr) * value.frames(rate)).max(1), + GridValue::FeetFrames(value) => to_samples(rate.samples_per_frame(sr) * value.frames()).max(1), + GridValue::Samples(n) => n.max(1), + } + } + + /// Nearest grid line to `pos`. + pub fn snap(&self, pos: Samples, sr: SampleRate, map: &TempoMap, rate: FrameRate) -> Samples { + if let Self::FeetFrames(value) = *self { + return value.snap(pos, sr, rate, 0); + } + let lo = self.line_at_or_before(pos, sr, map, rate); + let hi = self.next_line(lo, sr, map, rate); + if (pos - lo) <= (hi - pos) { lo } else { hi } + } + + /// The grid line at or before `pos`. + pub fn line_at_or_before(&self, pos: Samples, sr: SampleRate, map: &TempoMap, rate: FrameRate) -> Samples { + match *self { + Self::FeetFrames(value) => value.line_at_or_before(pos, sr, rate, 0), + GridValue::Note { value, dotted, triplet } => { + // Float ticks can land a hair either side of a line's own tick. + let mut t = map.samples_to_ticks_f(pos, sr).floor() as i64; + if map.tick_to_samples(t, sr) > pos { + t = t.saturating_sub(1); + } else if map.tick_to_samples(t.saturating_add(1), sr) <= pos { + t = t.saturating_add(1); + } + // Sub-bar values restart at each bar so odd meters stay aligned. A whole note is + // always four quarter notes and therefore keeps a continuous tick origin. + let bb = map.bar_beat_at_tick(t); + let bar_tick = map.bar_start_tick(bb.bar); + let origin = if value == NoteValue::Whole { 0 } else { bar_tick }; + let step = Self::note_ticks(value, dotted, triplet, map, origin); + let k = (t - origin).div_euclid(step); + map.tick_to_samples(origin + k * step, sr) + } + _ => self.abs_line(self.abs_index_at_or_before(pos, sr, rate), sr, rate), + } + } + + /// Position of absolute grid line `k` (Seconds, Frames, Samples), at an exact multiple of the step + /// rounded once, so rounding never accumulates from line to line. + fn abs_line(&self, k: i64, sr: SampleRate, rate: FrameRate) -> Samples { + if let Self::FeetFrames(value) = *self { + return value.sample_line(i128::from(k), sr, rate, 0); + } + if matches!(self, GridValue::Timecode(TimecodeValue::Second)) && rate.is_drop() { + return rate.samples_at_frame(Self::drop_second_frame(k, rate), sr); + } + if let Some(frames) = self.frame_step(rate) { + let whole = frames.round(); + if frames == whole { + return rate.samples_at_frame(k.saturating_mul(whole as i64), sr); + } + let step = (rate.samples_per_frame(sr) * frames).max(1.0); + return to_samples(k as f64 * step); + } + match *self { + GridValue::Seconds(s) => to_samples(k as f64 * Self::seconds_step(s, sr)), + GridValue::Samples(n) => k.saturating_mul(n.max(1)), + GridValue::Note { .. } => 0, + GridValue::Frames(_) | GridValue::Timecode(_) | GridValue::FeetFrames(_) => 0, + } + } + + /// Index of the absolute grid line at or before `pos`. + fn abs_index_at_or_before(&self, pos: Samples, sr: SampleRate, rate: FrameRate) -> i64 { + if matches!(self, GridValue::Timecode(TimecodeValue::Second)) && rate.is_drop() { + let tc = Timecode::from_frames(rate.frames_at(pos, sr), rate); + let magnitude = tc.hours.saturating_mul(3600).saturating_add(tc.minutes.saturating_mul(60)).saturating_add(tc.seconds); + let mut k = if tc.negative { magnitude.saturating_neg() } else { magnitude }; + while self.abs_line(k, sr, rate) > pos { + let previous = k.saturating_sub(1); + if previous == k { + break; + } + k = previous; + } + while self.abs_line(k.saturating_add(1), sr, rate) <= pos { + let next = k.saturating_add(1); + if next == k { + break; + } + k = next; + } + return k; + } + let mut k = if let Some(frames) = self.frame_step(rate) { + let step = (rate.samples_per_frame(sr) * frames).max(1.0); + (pos as f64 / step).floor() as i64 + } else { + match *self { + GridValue::Seconds(s) => (pos as f64 / Self::seconds_step(s, sr)).floor() as i64, + GridValue::Samples(n) => return pos.div_euclid(n.max(1)), + GridValue::Note { .. } => return 0, + GridValue::Frames(_) | GridValue::Timecode(_) | GridValue::FeetFrames(_) => return 0, + } + }; + // Lines are rounded to whole samples, so the floored index can be one off either way. + if self.abs_line(k, sr, rate) > pos { + k = k.saturating_sub(1); + } else if self.abs_line(k.saturating_add(1), sr, rate) <= pos { + k = k.saturating_add(1); + } + k + } + + /// The exact (fractional) Seconds step in samples, at least one sample. + fn seconds_step(s: f64, sr: SampleRate) -> f64 { + (s.clamp(0.000_01, 3600.0) * sr.as_f64()).max(1.0) + } + + /// Maps a signed whole-second timecode label to its physical frame at a drop-frame rate. + fn drop_second_frame(index: i64, rate: FrameRate) -> i64 { + let seconds = index.unsigned_abs(); + let total_minutes = seconds / 60; + let frames = if seconds.is_multiple_of(60) && !total_minutes.is_multiple_of(10) { if rate.nominal() >= 60 { 4 } else { 2 } } else { 0 }; + Timecode { + negative: index < 0, + hours: i64::try_from(seconds / 3600).unwrap_or(i64::MAX), + minutes: i64::try_from((seconds / 60) % 60).unwrap_or(0), + seconds: i64::try_from(seconds % 60).unwrap_or(0), + frames, + } + .to_frames(rate) + } + + /// A frame-based increment as a possibly fractional number of frames. + fn frame_step(&self, rate: FrameRate) -> Option { + match *self { + GridValue::Frames(frames) => Some(frames.clamp(1, 1_000_000) as f64), + GridValue::Timecode(value) => Some(value.frames(rate)), + GridValue::FeetFrames(value) => Some(value.frames()), + _ => None, + } + } + + /// The first grid line strictly after `line`. + pub fn next_line(&self, line: Samples, sr: SampleRate, map: &TempoMap, rate: FrameRate) -> Samples { + if let Self::FeetFrames(value) = *self { + return value.next_line(line, sr, rate, 0); + } + if !matches!(self, GridValue::Note { .. }) { + let next = self.abs_line(self.abs_index_at_or_before(line, sr, rate).saturating_add(1), sr, rate); + return if next > line { next } else { line.saturating_add(1) }; + } + let step = self.step_samples(line, sr, map, rate); + let candidate = self.line_at_or_before(line.saturating_add(step), sr, map, rate); + if candidate > line { candidate } else { line.saturating_add(step) } + } + + /// All grid lines within `[start, end)`, capped at `max` lines. + pub fn lines(&self, start: Samples, end: Samples, sr: SampleRate, map: &TempoMap, rate: FrameRate, max: usize) -> Vec { + if let Self::FeetFrames(value) = *self { + return value.lines(start, end, sr, rate, 0, max); + } + let mut out = Vec::new(); + let mut p = self.line_at_or_before(start, sr, map, rate); + while p < end && out.len() < max { + if p >= start { + out.push(p); + } + let n = self.next_line(p, sr, map, rate); + if n <= p { + break; + } + p = n; + } + out + } +} + +/// Moves one position through an ordered increment menu without wrapping. +fn step_enum(values: &[T], value: T, larger: bool) -> Option { + let index = values.iter().position(|candidate| *candidate == value)?; + let next = if larger { index.checked_sub(1) } else { index.checked_add(1) }?; + values.get(next).copied() +} + +#[cfg(test)] +mod tests { + use super::*; + + /// Frame origins are subtracted before rounding whole frames, quarter frames and subframes. + #[test] + fn feet_frames_origins_round_only_after_applying_the_frame_offset() { + let (sr, rate) = (SampleRate::HZ_48000, FrameRate::Fps29_97); + for at in [49_650, 100_901, 177_778] { + assert_eq!(FeetFramesValue::Foot.line_at_or_before(at, sr, rate, 1), at); + assert_eq!(FeetFramesValue::Foot.snap(at - 1, sr, rate, 1), at); + } + assert_eq!(FeetFramesValue::QuarterFrame.snap(50_049, sr, rate, 1), 50_050); + assert_eq!(FeetFramesValue::SubFrame.lines(49_665, 49_667, sr, rate, 1, 4), [49_666]); + } + + /// Dense subframes retain distinct sample lines and remain bounded with extreme signed origins. + #[test] + fn feet_frames_subframes_handle_dense_steps_and_extreme_origins() { + let sr = SampleRate::new(8_000).unwrap(); + let rate = FrameRate::Fps120; + for origin in [i64::MIN, -1, 0, 1, i64::MAX] { + assert_eq!(FeetFramesValue::SubFrame.lines(-3, 4, sr, rate, origin, 20), [-3, -2, -1, 0, 1, 2, 3]); + for at in [i64::MIN, -1, 0, 1, i64::MAX] { + assert_eq!(FeetFramesValue::SubFrame.line_at_or_before(at, sr, rate, origin), at); + assert_eq!(FeetFramesValue::SubFrame.snap(at, sr, rate, origin), at); + } + } + } + + #[test] + fn grid_lines_are_their_own_line_at_odd_tempos() { + let (g, sr, rate) = (GridValue::default(), SampleRate::HZ_48000, FrameRate::Fps30); + for bpm in [90.0, 95.0, 110.0, 130.0, 140.0] { + let map = TempoMap::new(bpm, 4, 4).unwrap(); + let mut line = 0; + for _ in 0..64 { + assert_eq!(g.line_at_or_before(line, sr, &map, rate), line, "{bpm} bpm"); + line = g.next_line(line, sr, &map, rate); + } + } + } + + /// Musical boundaries stay exact across a tempo change while absolute scales remain + /// independent of the tempo map. + #[test] + fn grids_stay_aligned_across_a_tempo_change() { + let (sr, rate) = (SampleRate::HZ_48000, FrameRate::Fps29_97Drop); + let mut map = TempoMap::default(); + map.set_tempo(TICKS_PER_QUARTER * 4, 90.0).unwrap(); + let musical = GridValue::Note { value: NoteValue::Quarter, dotted: false, triplet: false }; + for tick in [0, TICKS_PER_QUARTER * 3, TICKS_PER_QUARTER * 4, TICKS_PER_QUARTER * 5] { + let line = map.tick_to_samples(tick, sr); + assert_eq!(musical.line_at_or_before(line, sr, &map, rate), line); + } + for absolute in [ + GridValue::Seconds(0.5), + GridValue::Timecode(TimecodeValue::Second), + GridValue::FeetFrames(FeetFramesValue::Foot), + GridValue::Samples(1_000), + ] { + let line = absolute.next_line(absolute.line_at_or_before(100_000, sr, &map, rate), sr, &map, rate); + assert_eq!(absolute.line_at_or_before(line, sr, &map, rate), line, "{absolute:?}"); + } + } + + #[test] + fn sixteenth_grid_at_120() { + let g = GridValue::default(); + let (sr, map, rate) = (SampleRate::HZ_48000, TempoMap::default(), FrameRate::Fps30); + assert_eq!(g.step_samples(0, sr, &map, rate), 6000); + assert_eq!(g.snap(2900, sr, &map, rate), 0); + assert_eq!(g.snap(3100, sr, &map, rate), 6000); + assert_eq!(g.lines(0, 24_000, sr, &map, rate, 100), vec![0, 6000, 12000, 18000]); + } + + /// Absolute increments report their expected sample lengths at a fixed rate. + #[test] + fn absolute_grids() { + let (sr, map, rate) = (SampleRate::HZ_48000, TempoMap::default(), FrameRate::Fps25); + assert_eq!(GridValue::Seconds(1.0).snap(70_000, sr, &map, rate), 48_000); + assert_eq!(GridValue::Frames(1).step_samples(0, sr, &map, rate), 1920); + assert_eq!(GridValue::Timecode(TimecodeValue::Second).step_samples(0, sr, &map, rate), 48_000); + assert_eq!(GridValue::Timecode(TimecodeValue::FiveFrames).step_samples(0, sr, &map, rate), 9_600); + assert_eq!(GridValue::Timecode(TimecodeValue::HalfFrame).step_samples(0, sr, &map, rate), 960); + assert_eq!(GridValue::Timecode(TimecodeValue::QuarterFrame).step_samples(0, sr, &map, rate), 480); + assert_eq!(GridValue::Timecode(TimecodeValue::SubFrame).step_samples(0, sr, &map, rate), 19); + assert_eq!(GridValue::FeetFrames(FeetFramesValue::Foot).step_samples(0, sr, &map, rate), 30_720); + assert_eq!(GridValue::Samples(0).step_samples(0, sr, &map, rate), 1); + } + + /// Bar length follows the meter while a whole note always spans four quarter notes. + #[test] + fn a_bar_and_a_whole_note_differ_in_three_four() { + let (sr, map, rate) = (SampleRate::HZ_48000, TempoMap::new(120.0, 3, 4).unwrap(), FrameRate::Fps25); + let note = |value| GridValue::Note { value, dotted: false, triplet: false }; + assert_eq!(note(NoteValue::Bar).step_samples(0, sr, &map, rate), 72_000); + assert_eq!(note(NoteValue::Whole).step_samples(0, sr, &map, rate), 96_000); + assert_eq!(note(NoteValue::Bar).line_at_or_before(96_000, sr, &map, rate), 72_000); + assert_eq!(note(NoteValue::Whole).line_at_or_before(96_000, sr, &map, rate), 96_000); + assert_eq!(note(NoteValue::Half).lines(0, 150_000, sr, &map, rate, 10), [0, 48_000, 72_000, 120_000, 144_000]); + } + + #[test] + fn seconds_grid_lines_land_on_whole_seconds() { + let (sr, map, rate) = (SampleRate::HZ_44100, TempoMap::default(), FrameRate::Fps30); + for g in [GridValue::Seconds(0.001), GridValue::Seconds(0.01)] { + for n in 0..20 { + let s = n * 44_100; + assert_eq!(g.snap(s, sr, &map, rate), s, "{g:?} at {n} s"); + assert_eq!(g.line_at_or_before(s, sr, &map, rate), s, "{g:?} at {n} s"); + } + } + } + + /// Legacy frame grids stay aligned to exact frame starts at fractional rates. + #[test] + fn frame_grid_lines_land_on_frame_starts() { + let sr = SampleRate::HZ_48000; + let map = TempoMap::default(); + for rate in [FrameRate::Fps29_97, FrameRate::Fps23_976] { + for f in [1, 10] { + let g = GridValue::Frames(f); + for k in (0..5000).step_by(37) { + let s = rate.samples_at_frame(k, sr); + let line = rate.samples_at_frame(k.div_euclid(f) * f, sr); + assert_eq!(g.line_at_or_before(s, sr, &map, rate), line, "{rate:?} {f} frames at frame {k}"); + if k % f == 0 { + assert_eq!(g.snap(s, sr, &map, rate), s, "{rate:?} {f} frames at frame {k}"); + } + } + let start = rate.samples_at_frame(4000, sr); + let end = rate.samples_at_frame(4000 + 20 * f, sr); + let want: Vec<_> = (0..20).map(|i| rate.samples_at_frame(4000 + i * f, sr)).collect(); + assert_eq!(g.lines(start, end, sr, &map, rate, 100), want, "{rate:?} {f} frames"); + } + } + } + + /// Named whole- and sub-frame grids retain the exact-multiple calculation used by legacy frame grids. + #[test] + fn named_frame_grids_land_on_exact_subdivisions() { + let sr = SampleRate::HZ_48000; + let map = TempoMap::default(); + let grids = TimecodeValue::ALL.into_iter().map(GridValue::Timecode).chain(FeetFramesValue::ALL.into_iter().map(GridValue::FeetFrames)); + for rate in [FrameRate::Fps29_97, FrameRate::Fps23_976] { + for grid in grids.clone() { + for index in 0..200 { + let line = grid.abs_line(index, sr, rate); + assert_eq!(grid.line_at_or_before(line, sr, &map, rate), line, "{grid:?} {rate:?} line {index}"); + assert_eq!(grid.next_line(line, sr, &map, rate), grid.abs_line(index + 1, sr, rate), "{grid:?} {rate:?} line {index}"); + } + } + } + } + + /// Drop-frame second grids follow labelled second boundaries in both timeline directions. + #[test] + fn drop_frame_second_grids_follow_timecode_labels() { + let sr = SampleRate::HZ_48000; + let map = TempoMap::default(); + let grid = GridValue::Timecode(TimecodeValue::Second); + for rate in [FrameRate::Fps29_97Drop, FrameRate::Fps59_94Drop] { + for seconds in [0, 59, 60, 61, 600, 601, 3600] { + let frame = GridValue::drop_second_frame(seconds, rate); + let line = rate.samples_at_frame(frame, sr); + assert_eq!(grid.line_at_or_before(line, sr, &map, rate), line, "{rate:?} second {seconds}"); + assert_eq!(grid.line_at_or_before(-line, sr, &map, rate), -line, "{rate:?} second -{seconds}"); + for offset in -2..=rate.nominal() + 2 { + let position = rate.samples_at_frame(frame.saturating_add(offset), sr); + let before = grid.line_at_or_before(position, sr, &map, rate); + assert!(before <= position, "{rate:?} second {seconds}, frame offset {offset}"); + assert!(grid.next_line(before, sr, &map, rate) > position, "{rate:?} second {seconds}, frame offset {offset}"); + } + } + let labelled = Timecode { negative: false, hours: 0, minutes: 1, seconds: 1, frames: 0 }.to_samples(rate, sr); + assert_eq!(grid.snap(labelled, sr, &map, rate), labelled); + } + + for (rate, minute_frame) in [(FrameRate::Fps29_97Drop, 1800), (FrameRate::Fps59_94Drop, 3600)] { + let boundary = rate.samples_at_frame(minute_frame, sr); + assert_eq!(grid.line_at_or_before(boundary, sr, &map, rate), boundary, "{rate:?} positive minute boundary"); + assert_eq!(grid.snap(boundary, sr, &map, rate), boundary, "{rate:?} positive minute snap"); + assert_eq!(grid.line_at_or_before(-boundary, sr, &map, rate), -boundary, "{rate:?} negative minute boundary"); + assert_eq!(grid.snap(-boundary, sr, &map, rate), -boundary, "{rate:?} negative minute snap"); + } + } + + #[test] + fn lines_are_capped() { + let (sr, map, rate) = (SampleRate::HZ_48000, TempoMap::default(), FrameRate::Fps25); + assert_eq!(GridValue::Samples(1).lines(0, 1_000_000, sr, &map, rate, 50).len(), 50); + } + + #[test] + fn triplets_and_dots() { + let (sr, map, rate) = (SampleRate::HZ_48000, TempoMap::default(), FrameRate::Fps25); + let t = GridValue::Note { value: NoteValue::Quarter, dotted: false, triplet: true }; + assert_eq!(t.step_samples(0, sr, &map, rate), 16_000); + let d = GridValue::Note { value: NoteValue::Quarter, dotted: true, triplet: false }; + assert_eq!(d.step_samples(0, sr, &map, rate), 36_000); + } + + /// Stepping follows each menu ladder and stops at its endpoints. + #[test] + fn values_step_to_the_next_larger_or_smaller() { + let note = |value, triplet| GridValue::Note { value, dotted: false, triplet }; + assert_eq!(note(NoteValue::Sixteenth, false).stepped(true), note(NoteValue::Eighth, false)); + assert_eq!(note(NoteValue::Eighth, true).stepped(false), note(NoteValue::Sixteenth, true)); + assert_eq!(note(NoteValue::Bar, false).stepped(true), note(NoteValue::Bar, false)); + assert_eq!(note(NoteValue::SixtyFourth, false).stepped(false), note(NoteValue::SixtyFourth, false)); + assert_eq!(GridValue::Seconds(0.01).stepped(true), GridValue::Seconds(0.1)); + assert_eq!(GridValue::Seconds(0.5).stepped(false), GridValue::Seconds(0.1)); + assert_eq!(GridValue::Seconds(1.0).stepped(true), GridValue::Seconds(1.0)); + assert_eq!(GridValue::Frames(1).stepped(true), GridValue::Frames(10)); + assert_eq!(GridValue::Timecode(TimecodeValue::Frame).stepped(false), GridValue::Timecode(TimecodeValue::HalfFrame)); + assert_eq!(GridValue::FeetFrames(FeetFramesValue::Frame).stepped(true), GridValue::FeetFrames(FeetFramesValue::Foot)); + assert_eq!(GridValue::Samples(100).stepped(false), GridValue::Samples(10)); + assert_eq!(GridValue::Samples(1).stepped(true), GridValue::Samples(2)); + assert_eq!(GridValue::Samples(1).stepped(false), GridValue::Samples(1)); + } + + #[test] + fn snap_at_extreme_position_does_not_overflow() { + let sr = SampleRate::new(8000).unwrap(); + let map = TempoMap::new(1000.0, 4, 4).unwrap(); + let _ = GridValue::default().snap(i64::MAX, sr, &map, FrameRate::Fps30); + } +} diff --git a/crates/time/src/soundcraft/mod.rs b/crates/time/src/soundcraft/mod.rs new file mode 100644 index 000000000..bd4df3aa9 --- /dev/null +++ b/crates/time/src/soundcraft/mod.rs @@ -0,0 +1,155 @@ +//! SoundCraft timebases. +//! +//! Every position in a session is an absolute sample index (`i64`) at the session sample rate. +//! This crate converts those positions to and from the five Pro Tools-style timebases +//! (Bars|Beats, Min:Secs, Timecode, Feet+Frames, Samples) through a [`TempoMap`], and computes +//! grid lines and snapping for the Grid / Nudge values. +#![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)] + +mod format; +mod grid; +mod tempo; +mod timecode; + +pub use format::{TimeFormat, format_length, format_position, parse_position}; +pub use grid::{FeetFramesValue, GridScale, GridValue, NoteValue, TimecodeValue}; +pub use tempo::{BarBeat, MeterEvent, TICKS_PER_QUARTER, TempoEvent, TempoMap}; +pub use timecode::{FrameRate, Timecode}; + +/// A position or length in samples. +pub type Samples = i64; + +/// Largest position accepted from parameters and files: 2^40 samples (about 265 days at 48 kHz). +/// Clamping there keeps every later addition and subtraction on positions far away from overflow. +pub const MAX_POSITION: Samples = 1 << 40; + +/// Errors from parsing or converting time values. +#[derive(Debug, Clone, PartialEq, thiserror::Error)] +pub enum TimeError { + #[error("cannot parse `{0}` as a {1} value")] + Parse(String, &'static str), + #[error("invalid sample rate {0}")] + SampleRate(u32), + #[error("invalid tempo {0}")] + Tempo(f64), + #[error("invalid meter {0}/{1}")] + Meter(u32, u32), +} + +/// Sample rates supported by sessions (Pro Tools offers 44.1 k – 192 k; we accept 8 k – 768 k). +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, serde::Serialize, serde::Deserialize)] +pub struct SampleRate(u32); + +impl SampleRate { + pub const HZ_44100: SampleRate = SampleRate(44_100); + pub const HZ_48000: SampleRate = SampleRate(48_000); + pub const COMMON: [u32; 8] = [44_100, 48_000, 88_200, 96_000, 176_400, 192_000, 352_800, 384_000]; + + pub fn new(hz: u32) -> Result { + if (8_000..=768_000).contains(&hz) { Ok(SampleRate(hz)) } else { Err(TimeError::SampleRate(hz)) } + } + pub fn hz(self) -> u32 { + self.0 + } + pub fn as_f64(self) -> f64 { + f64::from(self.0) + } + /// Samples → seconds. + pub fn seconds(self, s: Samples) -> f64 { + s as f64 / self.as_f64() + } + /// Seconds → samples, rounded to nearest and saturated. + pub fn samples(self, seconds: f64) -> Samples { + to_samples(seconds * self.as_f64()) + } +} + +impl Default for SampleRate { + fn default() -> Self { + SampleRate::HZ_48000 + } +} + +/// Round a float sample count to `i64`, mapping NaN to 0 and saturating infinities. +pub fn to_samples(v: f64) -> Samples { + if v.is_nan() { + 0 + } else if v >= i64::MAX as f64 { + i64::MAX + } else if v <= i64::MIN as f64 { + i64::MIN + } else { + v.round() as i64 + } +} + +/// A half-open sample range `[start, end)`. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Default, serde::Serialize, serde::Deserialize)] +pub struct Range { + pub start: Samples, + pub end: Samples, +} + +impl Range { + pub fn new(a: Samples, b: Samples) -> Self { + if a <= b { Range { start: a, end: b } } else { Range { start: b, end: a } } + } + pub fn point(at: Samples) -> Self { + Range { start: at, end: at } + } + pub fn len(&self) -> Samples { + self.end.saturating_sub(self.start) + } + pub fn is_empty(&self) -> bool { + self.end <= self.start + } + pub fn contains(&self, s: Samples) -> bool { + s >= self.start && s < self.end + } + pub fn overlaps(&self, o: &Range) -> bool { + self.start < o.end && o.start < self.end + } + pub fn intersect(&self, o: &Range) -> Option { + let s = self.start.max(o.start); + let e = self.end.min(o.end); + (s < e).then_some(Range { start: s, end: e }) + } + pub fn shifted(&self, by: Samples) -> Range { + Range { start: self.start.saturating_add(by), end: self.end.saturating_add(by) } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn sample_rate_bounds() { + assert!(SampleRate::new(48_000).is_ok()); + assert!(SampleRate::new(0).is_err()); + assert!(SampleRate::new(10_000_000).is_err()); + } + + #[test] + fn seconds_round_trip() { + let sr = SampleRate::HZ_44100; + assert_eq!(sr.samples(1.0), 44_100); + assert!((sr.seconds(22_050) - 0.5).abs() < 1e-12); + } + + #[test] + fn to_samples_is_total() { + assert_eq!(to_samples(f64::NAN), 0); + assert_eq!(to_samples(f64::INFINITY), i64::MAX); + assert_eq!(to_samples(f64::NEG_INFINITY), i64::MIN); + } + + #[test] + fn range_ops() { + let a = Range::new(10, 0); + assert_eq!(a, Range { start: 0, end: 10 }); + assert_eq!(a.intersect(&Range::new(5, 20)), Some(Range::new(5, 10))); + assert_eq!(a.intersect(&Range::new(10, 20)), None); + assert!(a.contains(9) && !a.contains(10)); + } +} diff --git a/crates/time/src/soundcraft/tempo.rs b/crates/time/src/soundcraft/tempo.rs new file mode 100644 index 000000000..10f44b8dc --- /dev/null +++ b/crates/time/src/soundcraft/tempo.rs @@ -0,0 +1,622 @@ +//! Tempo and meter maps: the conversion between musical time (ticks) and samples. + +use super::{MAX_POSITION, SampleRate, Samples, TimeError, to_samples}; + +/// Ticks per quarter note (Pro Tools' resolution). +pub const TICKS_PER_QUARTER: i64 = 960; + +/// A tempo change at a tick position. `bpm` is quarter notes per minute. +#[derive(Debug, Clone, Copy, PartialEq, serde::Serialize, serde::Deserialize)] +pub struct TempoEvent { + pub tick: i64, + pub bpm: f64, +} + +/// A meter (time signature) change at a tick position, which must fall on a bar line. +#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize, serde::Deserialize)] +pub struct MeterEvent { + pub tick: i64, + pub numerator: u32, + pub denominator: u32, +} + +impl MeterEvent { + pub fn ticks_per_beat(&self) -> i64 { + let d = i64::from(self.denominator.max(1)); + (TICKS_PER_QUARTER * 4 / d).max(1) + } + pub fn ticks_per_bar(&self) -> i64 { + self.ticks_per_beat().saturating_mul(i64::from(self.numerator.max(1))) + } +} + +/// A musical position: 1-based bar and beat plus ticks within the beat. +#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize, serde::Deserialize)] +pub struct BarBeat { + pub bar: i64, + pub beat: i64, + pub tick: i64, +} + +/// Tempo and meter changes for a session. Always has at least one of each, at tick 0. +#[derive(Debug, Clone, PartialEq, serde::Serialize, serde::Deserialize)] +#[serde(from = "TempoMapData")] +pub struct TempoMap { + tempos: Vec, + meters: Vec, + /// For each tempo event, the elapsed time at its tick in quarter-note minutes (ticks / bpm, + /// summed over the earlier segments). Lets tick/sample conversions binary-search the segment + /// instead of walking every earlier tempo change, so converting many positions against a large + /// tempo map stays O(log n) each. Rebuilt whenever `tempos` changes. + #[serde(skip)] + elapsed: Vec, +} + +/// The serialized form of [`TempoMap`], without the derived lookup table. +#[derive(serde::Deserialize)] +struct TempoMapData { + tempos: Vec, + meters: Vec, +} + +impl From for TempoMap { + fn from(d: TempoMapData) -> Self { + TempoMap::from_parts(d.tempos, d.meters) + } +} + +impl Default for TempoMap { + fn default() -> Self { + TempoMap::from_parts(vec![TempoEvent { tick: 0, bpm: 120.0 }], vec![MeterEvent { tick: 0, numerator: 4, denominator: 4 }]) + } +} + +fn valid_bpm(bpm: f64) -> Result { + if bpm.is_finite() && (5.0..=1000.0).contains(&bpm) { Ok(bpm) } else { Err(TimeError::Tempo(bpm)) } +} + +fn valid_meter(numerator: u32, denominator: u32) -> bool { + (1..=64).contains(&numerator) && [1, 2, 4, 8, 16, 32, 64].contains(&denominator) +} + +impl TempoMap { + fn from_parts(tempos: Vec, meters: Vec) -> Self { + let mut m = TempoMap { tempos, meters, elapsed: Vec::new() }; + m.reindex(); + m + } + + /// Rebuild `elapsed` from `tempos`. The first segment runs from tick 0 at the first tempo. + fn reindex(&mut self) { + let mut elapsed = Vec::with_capacity(self.tempos.len()); + let mut acc = 0.0; + let mut seg_tick = 0i64; + let mut bpm = self.tempos.first().map_or(120.0, |e| e.bpm); + for (i, e) in self.tempos.iter().enumerate() { + if i > 0 { + // Saturating: a file's ticks are indexed before `sanitize` orders them. + acc += e.tick.saturating_sub(seg_tick) as f64 / bpm; + seg_tick = e.tick; + bpm = e.bpm; + } + elapsed.push(acc); + } + self.elapsed = elapsed; + } + + /// Samples per quarter-note minute (the unit of `elapsed`) at `sr`. + fn samples_per_elapsed(sr: SampleRate) -> f64 { + sr.as_f64() * 60.0 / TICKS_PER_QUARTER as f64 + } + + /// The tempo segment `i` (an index into `tempos`, 0 when it is empty): its start tick (0 for + /// the first), bpm and elapsed time at its start. + fn segment(&self, i: usize) -> (i64, f64, f64) { + let bpm = self.tempos.get(i).or(self.tempos.first()).map_or(120.0, |e| e.bpm); + let tick = if i == 0 { 0 } else { self.tempos.get(i).map_or(0, |e| e.tick) }; + (tick, bpm, self.elapsed.get(i).copied().unwrap_or(0.0)) + } + + pub fn new(bpm: f64, numerator: u32, denominator: u32) -> Result { + let mut m = TempoMap::default(); + m.set_tempo(0, bpm)?; + m.set_meter(0, numerator, denominator)?; + Ok(m) + } + + pub fn tempos(&self) -> &[TempoEvent] { + &self.tempos + } + pub fn meters(&self) -> &[MeterEvent] { + &self.meters + } + + /// Repair a map read from a file: only valid tempos and meters within `0..=MAX_POSITION` ticks, in + /// tick order, with one of each at tick 0. + pub fn sanitize(&mut self) { + self.tempos.retain(|e| (0..=MAX_POSITION).contains(&e.tick) && valid_bpm(e.bpm).is_ok()); + self.tempos.sort_by_key(|e| e.tick); + self.tempos.dedup_by_key(|e| e.tick); + if self.tempos.first().is_none_or(|e| e.tick > 0) { + let bpm = self.tempos.first().map_or(120.0, |e| e.bpm); + self.tempos.insert(0, TempoEvent { tick: 0, bpm }); + } + self.meters.retain(|m| (0..=MAX_POSITION).contains(&m.tick) && valid_meter(m.numerator, m.denominator)); + self.meters.sort_by_key(|m| m.tick); + self.meters.dedup_by_key(|m| m.tick); + if self.meters.first().is_none_or(|m| m.tick > 0) { + let (numerator, denominator) = self.meters.first().map_or((4, 4), |m| (m.numerator, m.denominator)); + self.meters.insert(0, MeterEvent { tick: 0, numerator, denominator }); + } + self.reindex(); + } + + /// Insert or replace the tempo at `tick`. + pub fn set_tempo(&mut self, tick: i64, bpm: f64) -> Result<(), TimeError> { + let bpm = valid_bpm(bpm)?; + let tick = tick.max(0); + match self.tempos.binary_search_by(|e| e.tick.cmp(&tick)) { + Ok(i) => { + if let Some(e) = self.tempos.get_mut(i) { + e.bpm = bpm; + } + } + Err(i) => self.tempos.insert(i, TempoEvent { tick, bpm }), + } + self.reindex(); + Ok(()) + } + + /// Remove a tempo event (the one at tick 0 is kept). + pub fn remove_tempo(&mut self, tick: i64) -> bool { + if tick == 0 { + return false; + } + let before = self.tempos.len(); + self.tempos.retain(|e| e.tick != tick); + self.reindex(); + before != self.tempos.len() + } + + /// Insert or replace the meter at `tick` (snapped to the bar line at or before it). + /// Later meter changes keep their bar numbers, so they stay on bar lines. + pub fn set_meter(&mut self, tick: i64, numerator: u32, denominator: u32) -> Result<(), TimeError> { + if !valid_meter(numerator, denominator) { + return Err(TimeError::Meter(numerator, denominator)); + } + let tick = if tick <= 0 { 0 } else { self.bar_start_tick(self.bar_beat_at_tick(tick).bar) }; + let later = self.take_meters_after(tick); + self.put_meter(MeterEvent { tick, numerator, denominator }); + self.restore_meters_at_bars(later); + Ok(()) + } + + /// Remove a meter event (the one at tick 0 is kept). Later meter changes keep their bar numbers. + pub fn remove_meter(&mut self, tick: i64) -> bool { + if tick == 0 || !self.meters.iter().any(|e| e.tick == tick) { + return false; + } + let later = self.take_meters_after(tick); + self.meters.retain(|e| e.tick != tick); + self.restore_meters_at_bars(later); + true + } + + fn put_meter(&mut self, ev: MeterEvent) { + match self.meters.binary_search_by(|e| e.tick.cmp(&ev.tick)) { + Ok(i) => { + if let Some(e) = self.meters.get_mut(i) { + *e = ev; + } + } + Err(i) => self.meters.insert(i, ev), + } + } + + /// Remove the meters after `tick`, returning each with the bar it starts: the bar + /// [`Self::bar_beat_at_tick`] gives its tick, counted in one pass over the map rather than one + /// walk per meter, so a session with many meter changes edits in linear time. + fn take_meters_after(&mut self, tick: i64) -> Vec<(i64, MeterEvent)> { + let mut later = Vec::new(); + let (mut bar, mut seg_tick) = (1i64, 0i64); + for (i, m) in self.meters.iter().enumerate() { + let tpb = m.ticks_per_bar(); + if m.tick > tick { + later.push((bar.saturating_add(m.tick.saturating_sub(seg_tick) / tpb), *m)); + } + if let Some(n) = self.meters.get(i + 1) { + bar = bar.saturating_add(ceil_div(n.tick.saturating_sub(seg_tick), tpb)); + seg_tick = n.tick; + } + } + self.meters.retain(|e| e.tick <= tick); + later + } + + /// The bar and tick of the last meter's segment, where [`Self::bar_start_tick`]'s walk ends. + fn last_segment(&self) -> (i64, i64) { + let (mut cur_bar, mut cur_tick) = (1i64, 0i64); + for (i, m) in self.meters.iter().enumerate() { + if let Some(n) = self.meters.get(i + 1) { + cur_bar = cur_bar.saturating_add(ceil_div(n.tick.saturating_sub(cur_tick), m.ticks_per_bar())); + cur_tick = n.tick; + } + } + (cur_bar, cur_tick) + } + + /// Put meters back at the start of their bars under the current earlier meters, in bar order. + /// Each lands at or past the last meter, so the walk to its bar resumes from there; a bar + /// before it (meters off their bar lines) takes the full walk. + fn restore_meters_at_bars(&mut self, later: Vec<(i64, MeterEvent)>) { + let (mut cur_bar, mut cur_tick) = self.last_segment(); + for (bar, ev) in later { + let tick = if bar >= cur_bar { + let tpb = self.meters.last().map_or(MeterEvent { tick: 0, numerator: 4, denominator: 4 }, |m| *m).ticks_per_bar(); + cur_tick.saturating_add(bar.saturating_sub(cur_bar).saturating_mul(tpb)) + } else { + self.bar_start_tick(bar) + }; + self.put_meter(MeterEvent { tick, ..ev }); + (cur_bar, cur_tick) = if bar >= cur_bar { (bar, tick) } else { self.last_segment() }; + } + } + + /// Tempo in effect at `tick`. + pub fn tempo_at_tick(&self, tick: i64) -> f64 { + let mut bpm = self.tempos.first().map_or(120.0, |e| e.bpm); + for e in &self.tempos { + if e.tick <= tick { + bpm = e.bpm; + } else { + break; + } + } + bpm + } + + /// Meter in effect at `tick`. + pub fn meter_at_tick(&self, tick: i64) -> MeterEvent { + let mut m = self.meters.first().copied().unwrap_or(MeterEvent { tick: 0, numerator: 4, denominator: 4 }); + for e in &self.meters { + if e.tick <= tick { + m = *e; + } else { + break; + } + } + m + } + + fn samples_per_tick(bpm: f64, sr: SampleRate) -> f64 { + sr.as_f64() * 60.0 / (bpm * TICKS_PER_QUARTER as f64) + } + + /// Ticks → samples (exact for constant-tempo segments). + pub fn tick_to_samples(&self, tick: i64, sr: SampleRate) -> Samples { + to_samples(self.tick_to_samples_f(tick, sr)) + } + + fn tick_to_samples_f(&self, tick: i64, sr: SampleRate) -> f64 { + // Tempo events after the first that start before `tick` (the segments it has passed). + let passed = self.tempos.get(1..).map_or(0, |rest| rest.partition_point(|e| e.tick < tick)); + let (seg_tick, bpm, elapsed) = self.segment(passed); + elapsed * Self::samples_per_elapsed(sr) + (tick - seg_tick) as f64 * Self::samples_per_tick(bpm, sr) + } + + /// Samples → ticks (fractional). + pub fn samples_to_ticks_f(&self, s: Samples, sr: SampleRate) -> f64 { + let s = s as f64; + let scale = Self::samples_per_elapsed(sr); + // Tempo events after the first that start at or before `s`. + let passed = self.elapsed.get(1..).map_or(0, |rest| rest.partition_point(|e| e * scale <= s)); + let (seg_tick, bpm, elapsed) = self.segment(passed); + seg_tick as f64 + (s - elapsed * scale) / Self::samples_per_tick(bpm, sr) + } + + pub fn samples_to_ticks(&self, s: Samples, sr: SampleRate) -> i64 { + to_samples(self.samples_to_ticks_f(s, sr)) + } + + /// Tick of the first beat of `bar` (1-based; bars before 1 extend the first meter backwards). + pub fn bar_start_tick(&self, bar: i64) -> i64 { + let mut cur_bar = 1i64; + let mut cur_tick = 0i64; + let first = self.meter_at_tick(0); + if bar < 1 { + return bar.saturating_sub(1).saturating_mul(first.ticks_per_bar()); + } + for (i, m) in self.meters.iter().enumerate() { + let next_tick = self.meters.get(i + 1).map(|n| n.tick); + let tpb = m.ticks_per_bar(); + let bars_in_seg = next_tick.map(|nt| ceil_div(nt.saturating_sub(cur_tick), tpb)); + match bars_in_seg { + Some(n) if bar >= cur_bar.saturating_add(n) => { + cur_bar = cur_bar.saturating_add(n); + cur_tick = next_tick.unwrap_or(cur_tick); + } + _ => return cur_tick.saturating_add((bar - cur_bar).saturating_mul(tpb)), + } + } + cur_tick + } + + /// Bar|beat|tick at `tick`. + pub fn bar_beat_at_tick(&self, tick: i64) -> BarBeat { + let first = self.meter_at_tick(0); + if tick < 0 { + let tpb = first.ticks_per_bar(); + let abs = tick.unsigned_abs(); + let tpb_u = tpb.unsigned_abs(); + let bars_back_u = abs / tpb_u + u64::from(!abs.is_multiple_of(tpb_u)); + let bars_back = i64::try_from(bars_back_u).unwrap_or(i64::MAX); + // Offset of `tick` inside its bar, without negating `i64::MIN`. + let within = ((tpb_u - abs % tpb_u) % tpb_u) as i64; + let beat_t = first.ticks_per_beat(); + return BarBeat { bar: 1 - bars_back, beat: within / beat_t + 1, tick: within % beat_t }; + } + let mut bar = 1i64; + let mut seg_tick = 0i64; + for (i, m) in self.meters.iter().enumerate() { + let tpb = m.ticks_per_bar(); + if let Some(n) = self.meters.get(i + 1) + && tick >= n.tick + { + bar = bar.saturating_add(ceil_div(n.tick.saturating_sub(seg_tick), tpb)); + seg_tick = n.tick; + continue; + } + let rel = tick - seg_tick; + let bars = rel / tpb; + let within = rel % tpb; + let beat_t = m.ticks_per_beat(); + return BarBeat { bar: bar + bars, beat: within / beat_t + 1, tick: within % beat_t }; + } + BarBeat { bar, beat: 1, tick: 0 } + } + + /// Bar|beat|tick → tick. + pub fn tick_at_bar_beat(&self, bb: BarBeat) -> i64 { + let start = self.bar_start_tick(bb.bar); + let m = self.meter_at_tick(start); + start.saturating_add(bb.beat.saturating_sub(1).max(0).saturating_mul(m.ticks_per_beat())).saturating_add(bb.tick) + } + + pub fn bar_beat_at(&self, s: Samples, sr: SampleRate) -> BarBeat { + self.bar_beat_at_tick(self.samples_to_ticks(s, sr)) + } + + pub fn samples_at_bar_beat(&self, bb: BarBeat, sr: SampleRate) -> Samples { + self.tick_to_samples(self.tick_at_bar_beat(bb), sr) + } + + /// Scale every tempo by `factor` (Tempo Operations › Scale). + pub fn scale_tempos(&mut self, factor: f64) -> Result<(), TimeError> { + let mut out = self.tempos.clone(); + for e in &mut out { + e.bpm = valid_bpm(e.bpm * factor)?; + } + self.tempos = out; + self.reindex(); + Ok(()) + } +} + +/// Ceiling division for a non-negative `num` and positive `den`, without overflow. +fn ceil_div(num: i64, den: i64) -> i64 { + num / den + i64::from(num % den != 0) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn default_map_is_120_4_4() { + let m = TempoMap::default(); + let sr = SampleRate::HZ_48000; + // One bar of 4/4 at 120 bpm = 2 seconds. + assert_eq!(m.samples_at_bar_beat(BarBeat { bar: 2, beat: 1, tick: 0 }, sr), 96_000); + assert_eq!(m.bar_beat_at(96_000, sr), BarBeat { bar: 2, beat: 1, tick: 0 }); + assert_eq!(m.bar_beat_at(24_000, sr), BarBeat { bar: 1, beat: 2, tick: 0 }); + } + + #[test] + fn meter_near_max_tick_does_not_overflow() { + let mut m = TempoMap::default(); + m.set_meter(i64::MAX, 7, 8).unwrap(); + let bb = m.bar_beat_at_tick(i64::MAX); + assert!(bb.bar > 1); + assert!(m.bar_start_tick(bb.bar) > 0); + assert!(m.bar_start_tick(i64::MAX) > 0); + } + + #[test] + fn minimum_bar_saturates_instead_of_overflowing() { + let m = TempoMap::default(); + assert_eq!(m.bar_start_tick(i64::MIN), i64::MIN); + assert_eq!(m.bar_start_tick(0), -TICKS_PER_QUARTER * 4); + } + + #[test] + fn minimum_beat_is_clamped_without_overflow() { + let m = TempoMap::default(); + assert_eq!(m.tick_at_bar_beat(BarBeat { bar: 1, beat: i64::MIN, tick: 0 }), 0); + } + + #[test] + fn bar_beat_at_min_tick_does_not_overflow() { + let m = TempoMap::default(); + let bb = m.bar_beat_at_tick(i64::MIN); + assert!(bb.bar < 1); + assert!(bb.beat >= 1 && bb.tick >= 0); + assert_eq!(m.bar_beat_at_tick(-1).bar, 0); + } + + #[test] + fn tempo_change_is_integrated() { + let mut m = TempoMap::default(); + let sr = SampleRate::HZ_48000; + m.set_tempo(TICKS_PER_QUARTER * 4, 60.0).unwrap(); + // Bar 1 at 120 = 96000 samples; bar 2 at 60 = 192000 samples. + assert_eq!(m.tick_to_samples(TICKS_PER_QUARTER * 8, sr), 96_000 + 192_000); + assert_eq!(m.samples_to_ticks(96_000 + 192_000, sr), TICKS_PER_QUARTER * 8); + } + + #[test] + fn meter_change_counts_bars() { + let mut m = TempoMap::default(); + // 2 bars of 4/4 then 3/4. + m.set_meter(TICKS_PER_QUARTER * 8, 3, 4).unwrap(); + assert_eq!(m.bar_beat_at_tick(TICKS_PER_QUARTER * 8), BarBeat { bar: 3, beat: 1, tick: 0 }); + assert_eq!(m.bar_beat_at_tick(TICKS_PER_QUARTER * 11), BarBeat { bar: 4, beat: 1, tick: 0 }); + assert_eq!(m.bar_start_tick(4), TICKS_PER_QUARTER * 11); + let bb = BarBeat { bar: 4, beat: 2, tick: 10 }; + assert_eq!(m.bar_beat_at_tick(m.tick_at_bar_beat(bb)), bb); + } + + #[test] + fn editing_an_early_meter_keeps_later_bars_and_scales_linearly() { + // 200 000 meter changes, one per bar, built directly: a walk per meter would take + // minutes to edit here, and even to build through `set_meter`. + let mut m = TempoMap::new(120.0, 4, 4).unwrap(); + let mut tick = 0; + for bar in 1..200_000i64 { + let numerator = 3 + (bar % 2) as u32; + tick += m.meters.last().map_or(0, MeterEvent::ticks_per_bar); + m.meters.push(MeterEvent { tick, numerator, denominator: 4 }); + } + let sample = [1usize, 1_000, 199_998]; + let bars = |m: &TempoMap| sample.map(|i| m.bar_beat_at_tick(m.meters[i].tick).bar); + assert_eq!(bars(&m), [2, 1_001, 199_999]); + m.set_meter(0, 7, 8).unwrap(); + assert_eq!(bars(&m), [2, 1_001, 199_999], "later meters keep their bar numbers"); + assert_eq!(m.meters.len(), 200_000); + assert!(m.meters.windows(2).all(|w| w[0].tick < w[1].tick)); + assert!(m.remove_meter(m.meters[1].tick)); + assert_eq!([1usize, 999, 199_997].map(|i| m.bar_beat_at_tick(m.meters[i].tick).bar), [3, 1_001, 199_999]); + // Meters off their bar lines (counted as bars 2 and 4) land where they always did: on + // bar 2 of the new meter, then two bars of their own later. + let mut off = TempoMap::new(120.0, 4, 4).unwrap(); + off.meters.push(MeterEvent { tick: 100, numerator: 3, denominator: 4 }); + off.meters.push(MeterEvent { tick: 5_000, numerator: 5, denominator: 4 }); + off.set_meter(0, 2, 4).unwrap(); + let placed: Vec<(i64, u32)> = off.meters.iter().map(|e| (e.tick, e.numerator)).collect(); + assert_eq!(placed, vec![(0, 2), (2 * TICKS_PER_QUARTER, 3), (8 * TICKS_PER_QUARTER, 5)]); + } + + #[test] + fn eighth_note_meters() { + let m = TempoMap::new(120.0, 6, 8).unwrap(); + assert_eq!(m.bar_beat_at_tick(TICKS_PER_QUARTER * 3), BarBeat { bar: 2, beat: 1, tick: 0 }); + assert_eq!(m.bar_beat_at_tick(TICKS_PER_QUARTER / 2), BarBeat { bar: 1, beat: 2, tick: 0 }); + } + + #[test] + fn sanitize_keeps_what_the_setters_accept() { + let tempo = |tick, bpm| TempoEvent { tick, bpm }; + let meter = |tick, numerator, denominator| MeterEvent { tick, numerator, denominator }; + let mut m = TempoMap::from_parts( + vec![tempo(960, 4e9), tempo(3840, 90.0), tempo(-5, 100.0), tempo(1920, 60.0), tempo(1920, 70.0)], + vec![meter(0, 0, 4), meter(3840, 3, 4)], + ); + m.sanitize(); + assert_eq!(m.tempos(), [tempo(0, 60.0), tempo(1920, 60.0), tempo(3840, 90.0)]); + assert_eq!(m.meters(), [meter(0, 3, 4), meter(3840, 3, 4)]); + let mut empty = TempoMap::from_parts(Vec::new(), Vec::new()); + empty.sanitize(); + assert_eq!(empty, TempoMap::default()); + } + + #[test] + fn sanitize_drops_events_beyond_the_position_range() { + let mut m = TempoMap::from_parts( + vec![TempoEvent { tick: 0, bpm: 120.0 }, TempoEvent { tick: i64::MAX, bpm: 90.0 }], + vec![MeterEvent { tick: 0, numerator: 4, denominator: 4 }, MeterEvent { tick: i64::MAX, numerator: 3, denominator: 4 }], + ); + m.sanitize(); + assert_eq!(m, TempoMap::default()); + assert_eq!(m.bar_start_tick(2), TICKS_PER_QUARTER * 4); + } + + #[test] + fn rejects_bad_values() { + let mut m = TempoMap::default(); + assert!(m.set_tempo(0, f64::NAN).is_err()); + assert!(m.set_tempo(0, 0.0).is_err()); + assert!(m.set_meter(0, 0, 4).is_err()); + assert!(m.set_meter(0, 4, 3).is_err()); + assert!(!m.remove_tempo(0)); + } + + #[test] + fn negative_positions() { + let m = TempoMap::default(); + assert_eq!(m.bar_beat_at_tick(-TICKS_PER_QUARTER * 4), BarBeat { bar: 0, beat: 1, tick: 0 }); + assert_eq!(m.bar_beat_at_tick(-TICKS_PER_QUARTER), BarBeat { bar: 0, beat: 4, tick: 0 }); + } + + /// The segment walk the indexed conversions replaced, kept as the reference they must match. + fn linear_tick_to_samples(m: &TempoMap, tick: i64, sr: SampleRate) -> i64 { + let mut acc = 0.0; + let mut seg_tick = 0i64; + let mut bpm = m.tempos[0].bpm; + for e in m.tempos.iter().skip(1) { + if e.tick >= tick { + break; + } + acc += (e.tick - seg_tick) as f64 * TempoMap::samples_per_tick(bpm, sr); + seg_tick = e.tick; + bpm = e.bpm; + } + to_samples(acc + (tick - seg_tick) as f64 * TempoMap::samples_per_tick(bpm, sr)) + } + + #[test] + fn indexed_conversions_match_the_segment_walk() { + let sr = SampleRate::HZ_48000; + let mut m = TempoMap::default(); + for i in 1..200i64 { + m.set_tempo(i * 487, 40.0 + ((i * 37) % 300) as f64).unwrap(); + } + assert!(m.remove_tempo(487 * 50)); + for tick in (-1000..200 * 487 + 5000).step_by(113) { + let s = m.tick_to_samples(tick, sr); + assert_eq!(s, linear_tick_to_samples(&m, tick, sr), "tick {tick}"); + assert!((m.samples_to_ticks(s, sr) - tick).abs() <= 1, "tick {tick}"); + } + let json = serde_json::to_string(&m).unwrap(); + assert!(!json.contains("elapsed")); + let back: TempoMap = serde_json::from_str(&json).unwrap(); + assert_eq!(back, m); + assert_eq!(back.tick_to_samples(150 * 487 + 3, sr), m.tick_to_samples(150 * 487 + 3, sr)); + } + + #[test] + fn a_file_with_wild_ticks_is_indexed_without_overflow() { + let json = r#"{"tempos":[{"tick":0,"bpm":120.0},{"tick":-9223372036854775808,"bpm":100.0},{"tick":9223372036854775807,"bpm":90.0}],"meters":[{"tick":0,"numerator":4,"denominator":4}]}"#; + let mut m: TempoMap = serde_json::from_str(json).unwrap(); + m.sanitize(); + assert_eq!(m.tempos().first().map(|e| e.tick), Some(0)); + assert_eq!(m.elapsed.len(), m.tempos().len()); + } + + #[test] + fn conversions_against_a_large_tempo_map_stay_fast() { + // 200k tempo changes, then 200k conversions each way: quadratic if every conversion walks + // the earlier segments, well under a second when it binary-searches them. + let sr = SampleRate::HZ_48000; + let n = 200_000i64; + let tempos = (0..n).map(|i| TempoEvent { tick: i * 10, bpm: 60.0 + (i % 7) as f64 }).collect(); + let mut m = TempoMap::from_parts(tempos, vec![MeterEvent { tick: 0, numerator: 4, denominator: 4 }]); + m.sanitize(); + let start = std::time::Instant::now(); + let mut sum = 0i64; + for i in 0..n { + let s = m.tick_to_samples(i * 10 + 5, sr); + sum = sum.wrapping_add(s).wrapping_add(m.samples_to_ticks(s, sr)); + } + assert!(sum != 0); + assert!(start.elapsed() < std::time::Duration::from_secs(5), "{:?}", start.elapsed()); + } +} diff --git a/crates/time/src/soundcraft/timecode.rs b/crates/time/src/soundcraft/timecode.rs new file mode 100644 index 000000000..03eb1cc94 --- /dev/null +++ b/crates/time/src/soundcraft/timecode.rs @@ -0,0 +1,309 @@ +//! SMPTE timecode and Feet+Frames. + +use super::{SampleRate, Samples, TimeError}; + +/// Session timecode rates. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Default, serde::Serialize, serde::Deserialize)] +pub enum FrameRate { + Fps23_976, + #[default] + Fps24, + Fps25, + Fps29_97, + Fps29_97Drop, + Fps30, + Fps47_952, + Fps48, + Fps50, + Fps59_94, + Fps59_94Drop, + Fps60, + Fps100, + Fps119_88, + Fps120, +} + +impl FrameRate { + pub const ALL: [FrameRate; 15] = [ + FrameRate::Fps23_976, + FrameRate::Fps24, + FrameRate::Fps25, + FrameRate::Fps29_97, + FrameRate::Fps29_97Drop, + FrameRate::Fps30, + FrameRate::Fps47_952, + FrameRate::Fps48, + FrameRate::Fps50, + FrameRate::Fps59_94, + FrameRate::Fps59_94Drop, + FrameRate::Fps60, + FrameRate::Fps100, + FrameRate::Fps119_88, + FrameRate::Fps120, + ]; + + /// Real frames per second as a rational (num, den). + pub fn rational(self) -> (i64, i64) { + match self { + FrameRate::Fps23_976 => (24_000, 1001), + FrameRate::Fps24 => (24, 1), + FrameRate::Fps25 => (25, 1), + FrameRate::Fps29_97 | FrameRate::Fps29_97Drop => (30_000, 1001), + FrameRate::Fps30 => (30, 1), + FrameRate::Fps47_952 => (48_000, 1001), + FrameRate::Fps48 => (48, 1), + FrameRate::Fps50 => (50, 1), + FrameRate::Fps59_94 | FrameRate::Fps59_94Drop => (60_000, 1001), + FrameRate::Fps60 => (60, 1), + FrameRate::Fps100 => (100, 1), + FrameRate::Fps119_88 => (120_000, 1001), + FrameRate::Fps120 => (120, 1), + } + } + + /// Frames per labelled second (the counting base). + pub fn nominal(self) -> i64 { + let (n, d) = self.rational(); + (n + d / 2) / d + } + + pub fn is_drop(self) -> bool { + matches!(self, FrameRate::Fps29_97Drop | FrameRate::Fps59_94Drop) + } + + pub fn label(self) -> &'static str { + match self { + FrameRate::Fps23_976 => "23.976", + FrameRate::Fps24 => "24", + FrameRate::Fps25 => "25", + FrameRate::Fps29_97 => "29.97", + FrameRate::Fps29_97Drop => "29.97 Drop", + FrameRate::Fps30 => "30", + FrameRate::Fps47_952 => "47.952", + FrameRate::Fps48 => "48", + FrameRate::Fps50 => "50", + FrameRate::Fps59_94 => "59.94", + FrameRate::Fps59_94Drop => "59.94 Drop", + FrameRate::Fps60 => "60", + FrameRate::Fps100 => "100", + FrameRate::Fps119_88 => "119.88", + FrameRate::Fps120 => "120", + } + } + + /// Whole frame index at a sample position (floored). + pub fn frames_at(self, s: Samples, sr: SampleRate) -> i64 { + let (n, d) = self.rational(); + let num = i128::from(s) * i128::from(n); + let den = i128::from(sr.hz()) * i128::from(d); + if den == 0 { + return 0; + } + i64::try_from(num.div_euclid(den)).unwrap_or(0) + } + + /// Sample position of the start of frame `f`. + pub fn samples_at_frame(self, f: i64, sr: SampleRate) -> Samples { + let (n, d) = self.rational(); + if n == 0 { + return 0; + } + // Round half away from zero so negative frames mirror positive ones. + let mag = (i128::from(f.unsigned_abs()) * i128::from(sr.hz()) * i128::from(d) + i128::from(n) / 2) / i128::from(n); + let v = if f < 0 { -mag } else { mag }; + i64::try_from(v).unwrap_or(if v < 0 { i64::MIN } else { i64::MAX }) + } + + /// Samples per frame (fractional). + pub fn samples_per_frame(self, sr: SampleRate) -> f64 { + let (n, d) = self.rational(); + sr.as_f64() * d as f64 / n as f64 + } +} + +/// A timecode label. +#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize, serde::Deserialize)] +pub struct Timecode { + pub negative: bool, + pub hours: i64, + pub minutes: i64, + pub seconds: i64, + pub frames: i64, +} + +impl Timecode { + /// Label for an absolute frame count. + pub fn from_frames(frames: i64, rate: FrameRate) -> Timecode { + let negative = frames < 0; + // Drop-frame labels can exceed i64::MAX even when the frame count fits. Widen before + // taking the magnitude so i64::MIN is represented exactly too. + let mut f = i128::from(frames).abs(); + let base = i128::from(rate.nominal().max(1)); + if rate.is_drop() { + // Drop 2 (or 4) labels each minute except every tenth. + let drop = if base >= 60 { 4 } else { 2 }; + let per_10min = base * 600 - drop * 9; + let per_min = base * 60 - drop; + let tens = f / per_10min; + let rem = f % per_10min; + let extra = if rem > drop { drop * 9 * tens + drop * ((rem - drop) / per_min) } else { drop * 9 * tens }; + f += extra; + } + let frames_l = f % base; + let total_secs = f / base; + Timecode { + negative, + hours: i64::try_from(total_secs / 3600).unwrap_or(i64::MAX), + minutes: i64::try_from((total_secs / 60) % 60).unwrap_or(0), + seconds: i64::try_from(total_secs % 60).unwrap_or(0), + frames: i64::try_from(frames_l).unwrap_or(0), + } + } + + /// Absolute frame count of this label. + pub fn to_frames(&self, rate: FrameRate) -> i64 { + let base = rate.nominal().max(1); + let total_min = i128::from(self.hours) * 60 + i128::from(self.minutes); + let mut f = (total_min * 60 + i128::from(self.seconds)) * i128::from(base) + i128::from(self.frames); + if rate.is_drop() { + let drop = if base >= 60 { 4 } else { 2 }; + f -= drop * (total_min - total_min / 10); + } + let f = if self.negative { -f } else { f }; + i64::try_from(f).unwrap_or(if f < 0 { i64::MIN } else { i64::MAX }) + } + + pub fn from_samples(s: Samples, rate: FrameRate, sr: SampleRate) -> Timecode { + Timecode::from_frames(rate.frames_at(s, sr), rate) + } + + pub fn to_samples(&self, rate: FrameRate, sr: SampleRate) -> Samples { + rate.samples_at_frame(self.to_frames(rate), sr) + } + + pub fn format(&self, rate: FrameRate) -> String { + let sep = if rate.is_drop() { ';' } else { ':' }; + let sign = if self.negative { "-" } else { "" }; + format!("{sign}{:02}:{:02}:{:02}{sep}{:02}", self.hours, self.minutes, self.seconds, self.frames) + } + + /// Parse `HH:MM:SS:FF` (also `;` / `.` separators and fewer fields, right-aligned). + pub fn parse(text: &str) -> Result { + let t = text.trim(); + let (negative, t) = match t.strip_prefix('-') { + Some(r) => (true, r), + None => (false, t), + }; + let parts: Vec<&str> = t.split([':', ';', '.']).collect(); + if parts.is_empty() || parts.len() > 4 { + return Err(TimeError::Parse(text.to_string(), "timecode")); + } + let mut nums = [0i64; 4]; + let offset = 4 - parts.len(); + for (i, p) in parts.iter().enumerate() { + let v: i64 = p.trim().parse().map_err(|_| TimeError::Parse(text.to_string(), "timecode"))?; + if v < 0 { + return Err(TimeError::Parse(text.to_string(), "timecode")); + } + if let Some(slot) = nums.get_mut(offset + i) { + *slot = v; + } + } + Ok(Timecode { negative, hours: nums[0], minutes: nums[1], seconds: nums[2], frames: nums[3] }) + } +} + +/// Feet+Frames for 35 mm film (16 frames per foot), `start` frames in at sample 0. +pub fn feet_frames(s: Samples, rate: FrameRate, sr: SampleRate, start: i64) -> (i64, i64) { + let f = rate.frames_at(s, sr).saturating_add(start); + (f.div_euclid(16), f.rem_euclid(16)) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn non_drop_round_trip() { + let sr = SampleRate::HZ_48000; + let tc = Timecode::from_samples(48_000 * 3661, FrameRate::Fps25, sr); + assert_eq!(tc.format(FrameRate::Fps25), "01:01:01:00"); + assert_eq!(tc.to_samples(FrameRate::Fps25, sr), 48_000 * 3661); + } + + #[test] + fn negative_timecode_rounds_symmetrically() { + let sr = SampleRate::HZ_48000; + let tc = Timecode::parse("-00:00:01:00").unwrap(); + assert_eq!(tc.to_samples(FrameRate::Fps25, sr), -48_000); + for r in [FrameRate::Fps25, FrameRate::Fps29_97, FrameRate::Fps29_97Drop, FrameRate::Fps23_976] { + for f in [1i64, 7, 25, 1799, 107_892] { + assert_eq!(r.samples_at_frame(-f, sr), -r.samples_at_frame(f, sr), "{r:?} frame {f}"); + } + } + } + + #[test] + fn drop_frame_labels() { + let r = FrameRate::Fps29_97Drop; + // Frame 1800 is the first frame of minute 1 → labelled 00:01:00;02. + assert_eq!(Timecode::from_frames(1800, r).format(r), "00:01:00;02"); + assert_eq!(Timecode::from_frames(1799, r).format(r), "00:00:59;29"); + // Minute 10 keeps its labels. + assert_eq!(Timecode::from_frames(17_982, r).format(r), "00:10:00;00"); + for f in [0, 1, 1799, 1800, 1801, 17_981, 17_982, 107_892, 500_000] { + assert_eq!(Timecode::from_frames(f, r).to_frames(r), f, "frame {f}"); + } + } + + #[test] + fn huge_timecode_saturates_instead_of_overflowing() { + let sr = SampleRate::HZ_48000; + for r in [FrameRate::Fps30, FrameRate::Fps29_97Drop] { + let tc = Timecode::parse("9223372036854775807:00:00:00").unwrap(); + assert_eq!(tc.to_samples(r, sr), i64::MAX); + let tc = Timecode::parse("-9223372036854775807:00:00:00").unwrap(); + assert_eq!(tc.to_samples(r, sr), i64::MIN); + } + let tc = Timecode::parse("01:02:03:04").unwrap(); + assert_eq!(tc.to_samples(FrameRate::Fps30, sr), 178_710_400); + } + + #[test] + fn extreme_frame_counts_round_trip_at_every_rate() { + for rate in FrameRate::ALL { + for frames in [i64::MIN, i64::MIN + 1, -1, 0, 1, i64::MAX - 1, i64::MAX] { + let tc = Timecode::from_frames(frames, rate); + assert_eq!(tc.to_frames(rate), frames, "{rate:?}: {frames}"); + assert_eq!(tc.negative, frames < 0); + assert!((0..60).contains(&tc.minutes)); + assert!((0..60).contains(&tc.seconds)); + assert!((0..rate.nominal()).contains(&tc.frames)); + assert_eq!(Timecode::parse(&tc.format(rate)).unwrap(), tc); + } + } + } + + #[test] + fn extreme_drop_frame_labels_do_not_overflow() { + for rate in [FrameRate::Fps29_97Drop, FrameRate::Fps59_94Drop] { + assert_eq!(Timecode::from_frames(i64::MAX, rate).to_frames(rate), i64::MAX); + } + } + + #[test] + fn parse_variants() { + let tc = Timecode::parse("01:02:03:04").unwrap(); + assert_eq!((tc.hours, tc.minutes, tc.seconds, tc.frames), (1, 2, 3, 4)); + let tc = Timecode::parse("3:04").unwrap(); + assert_eq!((tc.seconds, tc.frames), (3, 4)); + assert!(Timecode::parse("a:b").is_err()); + assert!(Timecode::parse("1:2:3:4:5").is_err()); + } + + #[test] + fn feet_and_frames() { + let sr = SampleRate::HZ_48000; + assert_eq!(feet_frames(48_000, FrameRate::Fps24, sr, 0), (1, 8)); + } +} From 5e24ea380e54d4366d9f7aa8c674b3a3833cb472 Mon Sep 17 00:00:00 2001 From: "Dylan M. Taylor" Date: Fri, 13 Nov 2026 07:47:44 +0000 Subject: [PATCH 4/5] Avoid duplicated timeline time primitives --- Cargo.lock | 1 + crates/time/src/lib.rs | 161 +++++++- crates/timeline/Cargo.toml | 1 + crates/timeline/src/time.rs | 632 +----------------------------- crates/timeline/src/time/tests.rs | 4 + 5 files changed, 164 insertions(+), 635 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 2dbe34bdd..3925c1028 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2264,6 +2264,7 @@ dependencies = [ name = "craft-timeline" version = "0.1.0" dependencies = [ + "craft-time", "craft-xml", "proptest", "serde", diff --git a/crates/time/src/lib.rs b/crates/time/src/lib.rs index 0d3703060..6cc780cf9 100644 --- a/crates/time/src/lib.rs +++ b/crates/time/src/lib.rs @@ -35,8 +35,7 @@ impl Tick { /// Seconds to ticks, clamped to [`Tick::MIN`]..=[`Tick::MAX`]. pub fn from_seconds_f64(s: f64) -> Tick { - let t = (s * TICKS_PER_SECOND as f64).round(); - Tick((t as i64).clamp(Tick::MIN.0, Tick::MAX.0)) + Tick((s * TICKS_PER_SECOND as f64).round() as i64) } pub fn seconds(self) -> f64 { @@ -246,7 +245,7 @@ impl TimeRange { } pub fn overlaps(&self, o: &TimeRange) -> bool { - self.start < o.end() && o.start < self.end() + !self.is_empty() && !o.is_empty() && self.start < o.end() && o.start < self.end() } pub fn intersect(&self, o: &TimeRange) -> Option { @@ -316,6 +315,19 @@ impl FrameRate { FrameRate::new((fps * 1000.0).round() as i64, 1000) } + /// Closest standard rate when `fps` is within `tolerance` of it, otherwise the nearest + /// 1/1000 rate. This retains the timeline API's coarse-timebase compatibility behavior. + pub fn from_f64_within(fps: f64, tolerance: f64) -> Self { + let mut best: Option<(f64, FrameRate)> = None; + for rate in Self::COMMON { + let off = (rate.as_f64() - fps).abs(); + if off <= tolerance * rate.as_f64() && best.is_none_or(|(closest, _)| off < closest) { + best = Some((off, rate)); + } + } + best.map_or_else(|| Self::from_f64(fps), |(_, rate)| rate) + } + /// Returns this frame rate if positive, or the default rate ([`FrameRate::FPS_23_976`]). /// /// ### Application Compatibility Note @@ -341,6 +353,11 @@ impl FrameRate { clamp_i64(frame_index(self.sane(), t)) } + /// Frames from `start` to `end`, counted between their frame indices. + pub fn frames_between(self, start: Tick, end: Tick) -> i64 { + self.frame_at(end).saturating_sub(self.frame_at(start)) + } + /// Start tick of frame `f` (ceiling division). pub fn tick_of(self, f: i64) -> Tick { Tick(clamp_i64(frame_boundary(self.sane(), i128::from(f)))) @@ -432,6 +449,9 @@ impl TimeDisplay { pub const ALL: [TimeDisplay; 6] = [TimeDisplay::Timecode, TimeDisplay::Feet35, TimeDisplay::Feet16, TimeDisplay::Frames, TimeDisplay::AudioSamples, TimeDisplay::Seconds]; + /// The video display formats (Sequence Settings ▸ Display Format, the burn-in's Time Format). + pub const VIDEO: [TimeDisplay; 4] = [TimeDisplay::Timecode, TimeDisplay::Feet35, TimeDisplay::Feet16, TimeDisplay::Frames]; + pub fn label(self) -> &'static str { match self { TimeDisplay::Timecode => "Timecode", @@ -442,6 +462,32 @@ impl TimeDisplay { TimeDisplay::Seconds => "Seconds", } } + + /// Stable id used by commands and the UI. + pub fn id(self) -> &'static str { + match self { + TimeDisplay::Timecode => "timecode", + TimeDisplay::Frames => "frames", + TimeDisplay::Feet16 => "feet16", + TimeDisplay::Feet35 => "feet35", + TimeDisplay::AudioSamples => "samples", + TimeDisplay::Seconds => "seconds", + } + } + + /// The display with this [`id`](Self::id), ignoring case. + pub fn from_id(id: &str) -> Option { + TimeDisplay::ALL.into_iter().find(|display| display.id().eq_ignore_ascii_case(id)) + } + + /// Frames per foot for the Feet + Frames displays. + pub fn frames_per_foot(self) -> Option { + match self { + TimeDisplay::Feet35 => Some(16), + TimeDisplay::Feet16 => Some(40), + _ => None, + } + } } fn df_params(rate: FrameRate) -> (i64, i64) { @@ -509,35 +555,33 @@ pub fn format_time(t: Tick, rate: FrameRate, drop_frame: bool, display: TimeDisp match display { TimeDisplay::Timecode => format_timecode_frames(frame, rate, drop_frame), TimeDisplay::Frames => format!("{frame}"), - TimeDisplay::Feet35 | TimeDisplay::Feet16 => { - let per_ft = if display == TimeDisplay::Feet35 { 16 } else { 40 }; - let neg = frame < 0; - let a = frame.unsigned_abs(); - format!("{}{}+{:02}", if neg { "-" } else { "" }, a / per_ft, a % per_ft) - } + TimeDisplay::Feet35 | TimeDisplay::Feet16 => format_feet_frames(frame, display.frames_per_foot().unwrap_or(16)), TimeDisplay::AudioSamples => { let neg = t.0 < 0; let abs_ticks = t.0.unsigned_abs(); let total_secs = abs_ticks / (TICKS_PER_SECOND as u64); let rem_ticks = abs_ticks % (TICKS_PER_SECOND as u64); - let rem_samples = (rem_ticks as u128 * sample_rate as u128) / (TICKS_PER_SECOND as u128); + let rem_samples = Tick(rem_ticks as i64).to_units_floor(sample_rate); let h = total_secs / 3600; let m = (total_secs / 60) % 60; let s = total_secs % 60; format!("{}{:02}:{:02}:{:02}:{:05}", if neg { "-" } else { "" }, h, m, s, rem_samples) } - TimeDisplay::Seconds => format!("{:.3}", t.seconds()), + TimeDisplay::Seconds => { + let seconds = format!("{:.3}", t.seconds()); + if seconds == "-0.000" { "0.000".into() } else { seconds } + } } } /// Format a frame count as Feet + Frames (film footage counting). pub fn format_feet_frames(frame: i64, per_foot: i64) -> String { - let pf = per_foot.max(1); + let per_foot = per_foot.max(1); let sign = if frame < 0 { "-" } else { "" }; - let a = frame.unsigned_abs(); - let pf = pf as u64; - let w = if pf > 100 { 3 } else { 2 }; - format!("{sign}{:04}+{:0w$}", a / pf, a % pf) + let frames = frame.unsigned_abs(); + let per_foot = per_foot as u64; + let width = if per_foot > 100 { 3 } else { 2 }; + format!("{sign}{:04}+{:0width$}", frames / per_foot, frames % per_foot) } /// Error from timecode parsing. @@ -561,6 +605,22 @@ impl std::error::Error for ParseError {} /// saturates to `i64::MAX` (or `i64::MIN`) to keep calculations total without errors. /// In contrast, EffectCraft returns `Err(ParseError("timecode too large"))`. pub fn parse_timecode(input: &str, rate: FrameRate, drop_frame: bool, current: i64) -> Result { + parse_timecode_with_negative_policy(input, rate, drop_frame, current, true) +} + +/// Parse timecode with the relative-negative policy used by the historical timeline API. +/// A leading minus offsets from `current`, including a complete four-field timecode. +pub fn parse_timecode_relative(input: &str, rate: FrameRate, drop_frame: bool, current: i64) -> Result { + parse_timecode_with_negative_policy(input, rate, drop_frame, current, false) +} + +fn parse_timecode_with_negative_policy( + input: &str, + rate: FrameRate, + drop_frame: bool, + current: i64, + full_negative_is_absolute: bool, +) -> Result { let s = input.trim(); if s.is_empty() { return Err(ParseError("empty timecode".into())); @@ -615,7 +675,10 @@ pub fn parse_timecode(input: &str, rate: FrameRate, drop_frame: bool, current: i let base = rate.timecode_base(); let frames = if nums.len() == 1 && sign_char.is_some() { fr - } else if drop_frame && rate.supports_drop_frame() && m < 60 && sec < 60 && fr < base { + } else if drop_frame && rate.supports_drop_frame() { + let (sec, fr) = (sec.saturating_add(fr / base), fr % base); + let (m, sec) = (m.saturating_add(sec / 60), sec % 60); + let (h, m) = (h.saturating_add(m / 60), m % 60); fields_to_frames(h, m, sec, fr, rate, true) } else { h.saturating_mul(3600).saturating_add(m.saturating_mul(60)).saturating_add(sec).saturating_mul(base).saturating_add(fr) @@ -623,10 +686,11 @@ pub fn parse_timecode(input: &str, rate: FrameRate, drop_frame: bool, current: i match sign_char { Some('+') => Ok(current.saturating_add(frames)), + Some('-') if full_negative_is_absolute && nums.len() == 4 => Ok(-frames), Some('-') => { // A full 4-field timecode with leading '-' represents an absolute negative timecode (e.g. preroll). - // Single-number or partial relative offsets (e.g. -15 or -1.00) offset from current. - if nums.len() == 4 { Ok(-frames) } else { Ok(current.saturating_sub(frames)) } + // The timeline compatibility path treats every leading minus as an offset instead. + Ok(current.saturating_sub(frames)) } None | Some(_) => Ok(frames), } @@ -671,6 +735,65 @@ pub fn parse_feet_frames(input: &str, per_foot: i64, current: i64) -> Result Result { + parse_time_display_with_negative_policy(input, rate, drop_frame, display, current, true) +} + +/// Parse text using a [`TimeDisplay`] representation and the historical timeline relative-minus rule. +pub fn parse_time_display_relative(input: &str, rate: FrameRate, drop_frame: bool, display: TimeDisplay, current: i64) -> Result { + parse_time_display_with_negative_policy(input, rate, drop_frame, display, current, false) +} + +fn parse_time_display_with_negative_policy( + input: &str, + rate: FrameRate, + drop_frame: bool, + display: TimeDisplay, + current: i64, + full_negative_is_absolute: bool, +) -> Result { + let per_foot = match display { + TimeDisplay::Frames => 1, + TimeDisplay::Feet35 | TimeDisplay::Feet16 => display.frames_per_foot().unwrap_or(16), + _ => { + return if full_negative_is_absolute { + parse_timecode(input, rate, drop_frame, current) + } else { + parse_timecode_relative(input, rate, drop_frame, current) + }; + } + }; + let text = input.trim(); + if text.is_empty() { + return Err(ParseError("empty time".into())); + } + let (relative, body) = match text.as_bytes()[0] { + b'+' => (Some(1), &text[1..]), + b'-' => (Some(-1), &text[1..]), + _ => (None, text), + }; + let field = |part: &str| -> Result { + if part.is_empty() { + Ok(0) + } else if !part.bytes().all(|byte| byte.is_ascii_digit()) { + Err(ParseError(format!("not a time: `{input}`"))) + } else { + part.parse::().map_err(|_| ParseError("number too large".into())) + } + }; + let parts: Vec<&str> = body.split(['+', ':', ';', '.', ',']).collect(); + let frames = match parts.as_slice() { + [number] => field(number)?, + [feet, frames] if per_foot > 1 => field(feet)?.saturating_mul(per_foot).saturating_add(field(frames)?), + _ => return Err(ParseError(format!("not a time: `{input}`"))), + }; + Ok(match relative { + Some(sign) => current.saturating_add(frames.saturating_mul(sign)), + None => frames, + }) +} + /// Common audio sample rates offered in sequence settings. pub const SAMPLE_RATES: [i64; 6] = [32_000, 44_100, 48_000, 88_200, 96_000, 192_000]; diff --git a/crates/timeline/Cargo.toml b/crates/timeline/Cargo.toml index 3eca9bc5c..9e73908e4 100644 --- a/crates/timeline/Cargo.toml +++ b/crates/timeline/Cargo.toml @@ -20,6 +20,7 @@ otio = ["dep:serde_json"] [dependencies] serde = { workspace = true, optional = true } +craft-time.workspace = true craft-xml = { workspace = true, optional = true } serde_json = { workspace = true, optional = true } diff --git a/crates/timeline/src/time.rs b/crates/timeline/src/time.rs index f3f12f58d..2db3def5b 100644 --- a/crates/timeline/src/time.rs +++ b/crates/timeline/src/time.rs @@ -1,627 +1,27 @@ -//! Exact media time. +//! Compatibility re-exports for timeline time primitives. //! -//! All time is an integer number of [`Tick`]s at [`TICKS_PER_SECOND`] = 254 016 000 000/s. That -//! rate divides evenly into every broadcast frame duration (23.976, 24, 25, 29.97, 30, 48, 50, -//! 59.94, 60, 120 …) and every common audio sample duration (8 k … 192 kHz, including the 44.1 k -//! family), so edits, frame math and audio alignment never drift. -//! -//! Timecode (SMPTE drop/non-drop, frames, feet+frames, samples) is only a *display* of ticks. -//! -//! Every conversion is total: a zero or negative rate (from a damaged file or project) gives -//! zero instead of a division-by-zero panic, and an invalid [`FrameRate`] counts as the default. +//! The implementation lives in `craft-time`; this module keeps the established +//! `craft_timeline::time::*` paths available to downstream users. -use std::fmt; -use std::ops::{Add, AddAssign, Neg, Sub, SubAssign}; +pub use craft_time::{ + FrameRate, ParseError, SAMPLE_RATES, TICKS_PER_SECOND, Tick, TimeDisplay, TimeRange, fields_to_frames, format_timecode_frames, frames_to_fields, + parse_time_display_relative as parse_time_display, parse_timecode_relative as parse_timecode, +}; -/// Ticks per second. -pub const TICKS_PER_SECOND: i64 = 254_016_000_000; - -/// A point or duration in time, in ticks. -/// Arithmetic saturates at the i64 storage limits; representable results remain exact. -#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, PartialOrd, Ord, Hash)] -#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] -#[cfg_attr(feature = "serde", serde(transparent))] -pub struct Tick(pub i64); - -impl Tick { - pub const ZERO: Tick = Tick(0); - pub const MAX: Tick = Tick(i64::MAX / 4); - pub const MIN: Tick = Tick(i64::MIN / 4); - - pub fn from_seconds_f64(s: f64) -> Tick { - Tick((s * TICKS_PER_SECOND as f64).round() as i64) - } - pub fn seconds(self) -> f64 { - self.0 as f64 / TICKS_PER_SECOND as f64 - } - /// Exact conversion from a count of `units` at `per_second` (e.g. samples at 48 000). - pub fn from_units(units: i64, per_second: i64) -> Tick { - Tick(clamp_i64((units as i128 * TICKS_PER_SECOND as i128).checked_div(per_second as i128).unwrap_or(0))) - } - /// Floor conversion to a count of units at `per_second`. - pub fn to_units_floor(self, per_second: i64) -> i64 { - clamp_i64((self.0 as i128 * per_second as i128).div_euclid(TICKS_PER_SECOND as i128)) - } - /// Conversion from a rational timestamp `pts * num / den` seconds (container timebases). - pub fn from_rational(pts: i64, num: i64, den: i64) -> Tick { - if den == 0 { - return Tick::ZERO; - } - let Some(t) = (pts as i128 * num as i128).checked_mul(TICKS_PER_SECOND as i128) else { - // A product beyond i128 divided by an i64 cannot fit back into i64. - return Tick(if (pts < 0) ^ (num < 0) ^ (den < 0) { i64::MIN } else { i64::MAX }); - }; - Tick(clamp_i64(t.div_euclid(den as i128))) - } - /// Inverse of [`Tick::from_rational`], floored to the timebase. - pub fn to_rational_floor(self, num: i64, den: i64) -> i64 { - clamp_i64((self.0 as i128 * den as i128).checked_div_euclid(num as i128 * TICKS_PER_SECOND as i128).unwrap_or(0)) - } - /// Inverse of [`Tick::from_rational`], rounded to the nearest timebase unit (halves up). - /// - /// For finding a sample by its timestamp: containers store timestamps rounded to their - /// timebase (Matroska usually to 1 ms), so a stamp can sit up to half a unit after the true - /// time. Flooring the requested time misses every frame whose stamp was rounded up. - pub fn to_rational_round(self, num: i64, den: i64) -> i64 { - let n = self.0 as i128 * den as i128; - let unit = num as i128 * TICKS_PER_SECOND as i128; - let (n, unit) = if unit < 0 { (-n, -unit) } else { (n, unit) }; - let (Some(q), Some(r)) = (n.checked_div_euclid(unit), n.checked_rem_euclid(unit)) else { return 0 }; - // 0 <= r < unit: round up from the halfway point - clamp_i64(if r * 2 >= unit { q + 1 } else { q }) - } - pub fn abs(self) -> Tick { - Tick(self.0.saturating_abs()) - } - pub fn min(self, o: Tick) -> Tick { - Tick(self.0.min(o.0)) - } - pub fn max(self, o: Tick) -> Tick { - Tick(self.0.max(o.0)) - } - pub fn clamp(self, lo: Tick, hi: Tick) -> Tick { - // max/min rather than `clamp`, which panics when the bounds cross. - Tick(self.0.max(lo.0).min(hi.0)) - } - /// Scale by a rational factor (`num/den`), flooring. - pub fn mul_ratio(self, num: i64, den: i64) -> Tick { - Tick(clamp_i64((self.0 as i128 * num as i128).checked_div_euclid(den as i128).unwrap_or(0))) - } -} - -impl Add for Tick { - type Output = Tick; - fn add(self, o: Tick) -> Tick { - Tick(self.0.saturating_add(o.0)) - } -} -impl Sub for Tick { - type Output = Tick; - fn sub(self, o: Tick) -> Tick { - Tick(self.0.saturating_sub(o.0)) - } -} -impl Neg for Tick { - type Output = Tick; - fn neg(self) -> Tick { - Tick(self.0.saturating_neg()) - } -} -impl AddAssign for Tick { - fn add_assign(&mut self, o: Tick) { - self.0 = self.0.saturating_add(o.0); - } -} -impl SubAssign for Tick { - fn sub_assign(&mut self, o: Tick) { - self.0 = self.0.saturating_sub(o.0); - } -} - -/// A half-open time range `[start, start + duration)`. -/// Zero or negative durations are empty and never overlap another range. -#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Hash)] -#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] -pub struct TimeRange { - pub start: Tick, - pub duration: Tick, -} - -impl TimeRange { - pub fn new(start: Tick, duration: Tick) -> Self { - Self { start, duration } - } - pub fn from_bounds(start: Tick, end: Tick) -> Self { - Self { start, duration: end - start } - } - pub fn end(&self) -> Tick { - self.start + self.duration - } - pub fn contains(&self, t: Tick) -> bool { - t >= self.start && t < self.end() - } - pub fn overlaps(&self, o: &TimeRange) -> bool { - !self.is_empty() && !o.is_empty() && self.start < o.end() && o.start < self.end() - } - pub fn intersect(&self, o: &TimeRange) -> Option { - let s = self.start.max(o.start); - let e = self.end().min(o.end()); - (e > s).then(|| TimeRange::from_bounds(s, e)) - } - pub fn is_empty(&self) -> bool { - self.duration.0 <= 0 - } -} - -/// A frame rate as an exact rational (`num / den` frames per second). -#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)] -#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] -pub struct FrameRate { - pub num: i64, - pub den: i64, -} - -impl Default for FrameRate { - fn default() -> Self { - FrameRate::FPS_23_976 - } -} - -impl FrameRate { - pub const FPS_23_976: FrameRate = FrameRate { num: 24000, den: 1001 }; - pub const FPS_24: FrameRate = FrameRate { num: 24, den: 1 }; - pub const FPS_25: FrameRate = FrameRate { num: 25, den: 1 }; - pub const FPS_29_97: FrameRate = FrameRate { num: 30000, den: 1001 }; - pub const FPS_30: FrameRate = FrameRate { num: 30, den: 1 }; - pub const FPS_48: FrameRate = FrameRate { num: 48, den: 1 }; - pub const FPS_50: FrameRate = FrameRate { num: 50, den: 1 }; - pub const FPS_59_94: FrameRate = FrameRate { num: 60000, den: 1001 }; - pub const FPS_60: FrameRate = FrameRate { num: 60, den: 1 }; - pub const FPS_119_88: FrameRate = FrameRate { num: 120000, den: 1001 }; - pub const FPS_120: FrameRate = FrameRate { num: 120, den: 1 }; - - /// Rates offered in sequence settings (Premiere's list). - pub const COMMON: [FrameRate; 11] = [ - Self::FPS_23_976, - Self::FPS_24, - Self::FPS_25, - Self::FPS_29_97, - Self::FPS_30, - Self::FPS_48, - Self::FPS_50, - Self::FPS_59_94, - Self::FPS_60, - Self::FPS_119_88, - Self::FPS_120, - ]; - - pub fn new(num: i64, den: i64) -> Self { - let g = i128::from(gcd(num.unsigned_abs(), den.unsigned_abs()).max(1)); - FrameRate { num: (i128::from(num) / g) as i64, den: (i128::from(den) / g) as i64 } - } - - /// Closest standard rate for a float (e.g. from a container's average rate). - pub fn from_f64(fps: f64) -> Self { - for r in Self::COMMON { - if (r.as_f64() - fps).abs() < 0.005 { - return r; - } - } - FrameRate::new((fps * 1000.0).round() as i64, 1000) - } - - /// Closest standard rate when `fps` is within `tolerance` of it, `tolerance` a fraction of the - /// rate (`0.005` is half a percent), else the nearest 1/1000 rate. A rate a container reports - /// from timestamps rounded to a coarse unit lands slightly off a standard rate: at 90 kHz, - /// whole milliseconds leave a 30 fps clip's average at 30.02 or 29.95 (#1700). - pub fn from_f64_within(fps: f64, tolerance: f64) -> Self { - let mut best: Option<(f64, FrameRate)> = None; - for r in Self::COMMON { - let off = (r.as_f64() - fps).abs(); - if off <= tolerance * r.as_f64() && best.is_none_or(|(closest, _)| off < closest) { - best = Some((off, r)); - } - } - best.map_or_else(|| Self::from_f64(fps), |(_, r)| r) - } - - /// This rate if it is positive, else the default (a damaged file or project can carry a zero - /// or negative rate; every frame computation uses this so it can't divide by zero). - pub fn sane(self) -> FrameRate { - if self.num > 0 && self.den > 0 { self } else { FrameRate::default() } - } - - pub fn as_f64(self) -> f64 { - self.num as f64 / self.den as f64 - } - - /// Exact duration of one frame (rounded down only for exotic rates). - /// Values outside the integer tick range saturate at its limit. - pub fn frame_duration(self) -> Tick { - let r = self.sane(); - Tick(clamp_i64((TICKS_PER_SECOND as i128 * r.den as i128) / r.num as i128)) - } - - /// Index of the frame containing `t` (floor), saturated to the integer frame range. - pub fn frame_at(self, t: Tick) -> i64 { - clamp_i64(frame_index(self.sane(), t)) - } - - /// Frames from `start` to `end`, counted between their frame indices. `frame_at(end - start)` - /// can read one short when a frame is not a whole number of ticks: the ticks between two - /// boundaries can be one fewer than the exact length of those frames. - pub fn frames_between(self, start: Tick, end: Tick) -> i64 { - self.frame_at(end).saturating_sub(self.frame_at(start)) - } - - /// Start tick of frame `f` (ceiling division). - /// - /// Uses ceiling (not floor) so that `frame_at(tick_of(f)) == f` holds for every - /// representable frame boundary with at least one tick per frame, even when the frame duration is not an integer number of ticks - /// (exotic rates like 37.516 fps where `TICKS_PER_SECOND * den / num` has a - /// fractional remainder). Floor division truncates the remainder, causing each - /// frame boundary to drift backward by one tick until `frame_at` returns `f - 1` - /// instead of `f` — frame stepping then stalls or moves backward. - /// Values outside the integer tick range saturate at its limit. - pub fn tick_of(self, f: i64) -> Tick { - Tick(clamp_i64(frame_boundary(self.sane(), i128::from(f)))) - } - - /// Snap `t` down to a frame boundary. - pub fn snap(self, t: Tick) -> Tick { - let r = self.sane(); - Tick(clamp_i64(frame_boundary(r, frame_index(r, t)))) - } - - /// Snap `t` to the nearest frame boundary. - pub fn snap_nearest(self, t: Tick) -> Tick { - let r = self.sane(); - // Keep the frame index and both boundaries wide until choosing the nearest. Saturating - // an unrepresentable upper boundary first would make it seem closer than it really is. - let f = frame_index(r, t); - let a = frame_boundary(r, f); - let b = frame_boundary(r, f.saturating_add(1)); - let t = i128::from(t.0); - Tick(clamp_i64(if t - a <= b - t { a } else { b })) - } - - /// Timecode base (frames counted per timecode second): 30 for 29.97, 24 for 23.976. - pub fn timecode_base(self) -> i64 { - let r = self.sane(); - (r.num / r.den).saturating_add(i64::from(r.num % r.den != 0)).max(1) - } - - /// NTSC (x/1001) rates can use drop-frame timecode. - pub fn is_ntsc(self) -> bool { - self.den == 1001 - } - - /// Whether drop-frame counting applies (29.97 / 59.94 / 119.88). - pub fn supports_drop_frame(self) -> bool { - self.is_ntsc() && self.timecode_base() % 30 == 0 - } - - pub fn label(self) -> String { - if self.den == 1 { - format!("{}", self.num) - } else { - let v = self.as_f64(); - let s = format!("{v:.3}"); - let s = s.trim_end_matches('0').trim_end_matches('.'); - s.to_string() - } - } -} - -impl fmt::Display for FrameRate { - fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { - write!(f, "{} fps", self.label()) - } -} - -/// Narrow only after applying the storage bounds, never by a wrapping integer cast. -fn clamp_i64(value: i128) -> i64 { - value.clamp(i128::from(i64::MIN), i128::from(i64::MAX)) as i64 -} - -/// Callers pass a positive rate. Products of two i64 values fit in i128. -fn frame_index(rate: FrameRate, t: Tick) -> i128 { - (i128::from(t.0) * i128::from(rate.num)).div_euclid(i128::from(TICKS_PER_SECOND) * i128::from(rate.den)) -} - -/// Ceiling of the rational boundary. An overflowing numerator divided by an i64 rate cannot -/// fit in an i64 tick, so saturating that intermediate preserves the public result. Boundaries -/// derived from frame_index remain within i128 (at most one frame away from an i64 tick). -fn frame_boundary(rate: FrameRate, frame: i128) -> i128 { - let Some(n) = frame.checked_mul(i128::from(TICKS_PER_SECOND)).and_then(|n| n.checked_mul(i128::from(rate.den))) else { - return if frame < 0 { i128::MIN } else { i128::MAX }; - }; - let d = i128::from(rate.num); - // Flooring a fractional boundary puts the tick in the preceding frame. - n.div_euclid(d) + i128::from(n.rem_euclid(d) != 0) -} - -fn gcd(mut a: u64, mut b: u64) -> u64 { - while b != 0 { - (a, b) = (b, a % b); - } - a -} - -/// How time is displayed (Premiere: Timecode, Feet+Frames 16mm/35mm, Frames, Audio Samples). -#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Hash)] -#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] -pub enum TimeDisplay { - #[default] - Timecode, - Frames, - Feet16, - Feet35, - AudioSamples, - Seconds, -} - -impl TimeDisplay { - pub const ALL: [TimeDisplay; 6] = - [TimeDisplay::Timecode, TimeDisplay::Feet35, TimeDisplay::Feet16, TimeDisplay::Frames, TimeDisplay::AudioSamples, TimeDisplay::Seconds]; - pub fn label(self) -> &'static str { - match self { - TimeDisplay::Timecode => "Timecode", - TimeDisplay::Frames => "Frames", - TimeDisplay::Feet16 => "Feet + Frames 16mm", - TimeDisplay::Feet35 => "Feet + Frames 35mm", - TimeDisplay::AudioSamples => "Audio Samples", - TimeDisplay::Seconds => "Seconds", - } - } -} - -impl TimeDisplay { - /// The video display formats (Sequence Settings ▸ Display Format, the burn-in's Time Format). - pub const VIDEO: [TimeDisplay; 4] = [TimeDisplay::Timecode, TimeDisplay::Feet35, TimeDisplay::Feet16, TimeDisplay::Frames]; - - /// Stable id used by commands and the UI: `timecode`, `feet35`, `feet16`, `frames`, `samples`, `seconds`. - pub fn id(self) -> &'static str { - match self { - TimeDisplay::Timecode => "timecode", - TimeDisplay::Frames => "frames", - TimeDisplay::Feet16 => "feet16", - TimeDisplay::Feet35 => "feet35", - TimeDisplay::AudioSamples => "samples", - TimeDisplay::Seconds => "seconds", - } - } - - /// The display with this [`id`](Self::id), ignoring case. - pub fn from_id(id: &str) -> Option { - TimeDisplay::ALL.into_iter().find(|d| d.id().eq_ignore_ascii_case(id)) - } - - /// Frames per foot of film for the Feet + Frames displays: 16 for 35mm 4-perf, 40 for 16mm. - pub fn frames_per_foot(self) -> Option { - match self { - TimeDisplay::Feet35 => Some(16), - TimeDisplay::Feet16 => Some(40), - _ => None, - } - } -} - -/// Format a frame count as film feet + frames (`F+FF`, e.g. `2+08`; negative: `-2+08`). -/// `frames_per_foot` below 1 is treated as 1 so a bad value never divides by zero. +/// Format a timeline frame count as film feet + frames, retaining its established compact display. pub fn format_feet_frames(frame: i64, frames_per_foot: i64) -> String { - let per_ft = frames_per_foot.max(1).unsigned_abs(); - let a = frame.unsigned_abs(); - format!("{}{}+{:02}", if frame < 0 { "-" } else { "" }, a / per_ft, a % per_ft) + let per_foot = frames_per_foot.max(1).unsigned_abs(); + let frames = frame.unsigned_abs(); + format!("{}{}+{:02}", if frame < 0 { "-" } else { "" }, frames / per_foot, frames % per_foot) } -/// Drop-frame parameters: frames dropped per minute and nominal base. -fn df_params(rate: FrameRate) -> (i64, i64) { - let base = rate.timecode_base(); - (base / 15, base) // 30 → 2, 60 → 4, 120 → 8 -} - -/// Convert a frame count to SMPTE fields `(negative, h, m, s, f)`. -pub fn frames_to_fields(frame: i64, rate: FrameRate, drop_frame: bool) -> (bool, i64, i64, i64, i64) { - let neg = frame < 0; - let mut n = i128::from(frame).abs(); - let (drop, base) = df_params(rate); - let (drop, base) = (i128::from(drop), i128::from(base)); - if drop_frame && rate.supports_drop_frame() { - let per_min = base * 60 - drop; - let per_10 = per_min * 10 + drop; - let d = n / per_10; - let m = n % per_10; - n = n.saturating_add(drop * 9 * d); - if m > drop { - n = n.saturating_add(drop * ((m - drop) / per_min)); - } +/// Format time using the timeline's historical compact Feet + Frames display. +pub fn format_time(tick: Tick, rate: FrameRate, drop_frame: bool, display: TimeDisplay, sample_rate: i64) -> String { + if let TimeDisplay::Feet35 | TimeDisplay::Feet16 = display { + return format_feet_frames(rate.frame_at(tick), display.frames_per_foot().unwrap_or(16)); } - let f = n % base; - let s = (n / base) % 60; - let mi = (n / (base * 60)) % 60; - let h = n / (base * 3600); - (neg, clamp_i64(h), clamp_i64(mi), clamp_i64(s), clamp_i64(f)) + craft_time::format_time(tick, rate, drop_frame, display, sample_rate) } -/// Convert SMPTE fields back to a frame count. -pub fn fields_to_frames(h: i64, m: i64, s: i64, f: i64, rate: FrameRate, drop_frame: bool) -> i64 { - let (drop, base) = df_params(rate); - let seconds = i128::from(h) * 3600 + i128::from(m) * 60 + i128::from(s); - // Nominal drop-frame labels can exceed i64 even when the final frame count fits. - let mut n = seconds.saturating_mul(i128::from(base)).saturating_add(i128::from(f)); - if drop_frame && rate.supports_drop_frame() { - let total_min = i128::from(h) * 60 + i128::from(m); - n = n.saturating_sub(i128::from(drop) * (total_min - total_min / 10)); - } - clamp_i64(n) -} - -/// Format a frame count as SMPTE timecode (`HH:MM:SS:FF`, or `HH;MM;SS;FF` for drop-frame). -pub fn format_timecode_frames(frame: i64, rate: FrameRate, drop_frame: bool) -> String { - let df = drop_frame && rate.supports_drop_frame(); - let (neg, h, m, s, f) = frames_to_fields(frame, rate, df); - let sep = if df { ';' } else { ':' }; - let fw = if rate.timecode_base() >= 100 { 3 } else { 2 }; - format!("{}{h:02}{sep}{m:02}{sep}{s:02}{sep}{f:0fw$}", if neg { "-" } else { "" }) -} - -/// Format a tick according to a display mode. -pub fn format_time(t: Tick, rate: FrameRate, drop_frame: bool, display: TimeDisplay, sample_rate: i64) -> String { - let frame = rate.frame_at(t); - match display { - TimeDisplay::Timecode => format_timecode_frames(frame, rate, drop_frame), - TimeDisplay::Frames => format!("{frame}"), - TimeDisplay::Feet35 | TimeDisplay::Feet16 => format_feet_frames(frame, display.frames_per_foot().unwrap_or(16)), - TimeDisplay::AudioSamples => { - let neg = t.0 < 0; - let a = t.0.unsigned_abs(); - let tps = TICKS_PER_SECOND.unsigned_abs(); - let secs = a / tps; - let rem = Tick((a % tps) as i64).to_units_floor(sample_rate); - format!("{}{:02}:{:02}:{:02}:{rem:05}", if neg { "-" } else { "" }, secs / 3600, (secs / 60) % 60, secs % 60) - } - TimeDisplay::Seconds => { - let s = format!("{:.3}", t.seconds()); - // A tiny negative time rounds to zero but keeps its sign: drop it. - if s == "-0.000" { "0.000".to_string() } else { s } - } - } -} - -/// Error from [`parse_timecode`]. -#[derive(Debug, Clone, PartialEq, Eq)] -pub struct ParseError(pub String); - -impl fmt::Display for ParseError { - fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { - f.write_str(&self.0) - } -} -impl std::error::Error for ParseError {} - -/// Parse user-typed timecode into a frame count, the way Premiere's timecode fields do: -/// - `01:02:03:04`, `01;02;03;04`, `1.2.3.4` (any of `:;.,` separators), -/// - bare digits are read right-aligned as `HHMMSSFF` (`1000` = 00:00:10:00), -/// - a leading `+`/`-` makes the value relative to `current` (`+15` = 15 frames later, `-1.00` = 1 s earlier). -pub fn parse_timecode(input: &str, rate: FrameRate, drop_frame: bool, current: i64) -> Result { - let s = input.trim(); - if s.is_empty() { - return Err(ParseError("empty timecode".into())); - } - let (rel, body) = match s.as_bytes()[0] { - b'+' => (Some(1), &s[1..]), - b'-' => (Some(-1), &s[1..]), - _ => (None, s), - }; - let parts: Vec<&str> = body.split([':', ';', '.', ',']).collect(); - let nums: Vec = if parts.len() == 1 { - let digits = parts[0]; - if digits.is_empty() || !digits.bytes().all(|b| b.is_ascii_digit()) { - return Err(ParseError(format!("not a timecode: `{input}`"))); - } - // Right-aligned pairs: "12345" -> [1, 23, 45]; relative bare numbers are frames. - if rel.is_some() { - vec![digits.parse().map_err(|_| ParseError("number too large".into()))?] - } else { - let mut v = Vec::new(); - let b = digits.as_bytes(); - let mut end = b.len(); - while end > 0 { - let start = end.saturating_sub(2); - v.push(digits[start..end].parse::().unwrap_or(0)); - end = start; - } - v.reverse(); - v - } - } else { - parts - .iter() - .map(|p| { - if p.is_empty() { - Ok(0) - } else if !p.bytes().all(|b| b.is_ascii_digit()) { - Err(ParseError(format!("bad field `{p}`"))) - } else { - p.parse::().map_err(|_| ParseError(format!("bad field `{p}`"))) - } - }) - .collect::>()? - }; - if nums.len() > 4 { - return Err(ParseError("too many timecode fields".into())); - } - let mut f4 = [0i64; 4]; - let off = 4 - nums.len(); - f4[off..].copy_from_slice(&nums); - let [h, m, sec, fr] = f4; - // Overflowing fields (e.g. 90 frames) are allowed, as in Premiere. - let base = rate.timecode_base(); - let frames = if nums.len() == 1 && rel.is_some() { - fr - } else if drop_frame && rate.supports_drop_frame() { - // overflowing fields carry into the next one first, then drop-frame counting applies - let (sec, fr) = (sec.saturating_add(fr / base), fr % base); - let (m, sec) = (m.saturating_add(sec / 60), sec % 60); - let (h, m) = (h.saturating_add(m / 60), m % 60); - fields_to_frames(h, m, sec, fr, rate, true) - } else { - // Saturating: typed or agent-sent timecodes can have absurdly large fields. - h.saturating_mul(3600).saturating_add(m.saturating_mul(60)).saturating_add(sec).saturating_mul(base).saturating_add(fr) - }; - Ok(match rel { - Some(sign) => current.saturating_add(frames.saturating_mul(sign)), - None => frames, - }) -} - -/// Parse typed text in the given display format into a frame count. -/// -/// - [`TimeDisplay::Frames`]: a plain frame count (`240`). -/// - [`TimeDisplay::Feet35`] / [`TimeDisplay::Feet16`]: `F+FF` (`2+08`; `.`, `:`, `;` and `,` also -/// separate); a bare number is a frame count. A frames field past the foot rolls over -/// (`1+20` at 35mm is 36 frames), as the timecode parser does. -/// - A leading `+` / `-` makes the value relative to `current` (a bare relative number is frames). -/// - Every other display parses as timecode ([`parse_timecode`]). -pub fn parse_time_display(input: &str, rate: FrameRate, drop_frame: bool, display: TimeDisplay, current: i64) -> Result { - let per_ft = match display { - TimeDisplay::Frames => 1, - TimeDisplay::Feet35 | TimeDisplay::Feet16 => display.frames_per_foot().unwrap_or(16), - _ => return parse_timecode(input, rate, drop_frame, current), - }; - let s = input.trim(); - if s.is_empty() { - return Err(ParseError("empty time".into())); - } - let (rel, body) = match s.as_bytes()[0] { - b'+' => (Some(1), &s[1..]), - b'-' => (Some(-1), &s[1..]), - _ => (None, s), - }; - let field = |p: &str| -> Result { - if p.is_empty() { - Ok(0) - } else if !p.bytes().all(|b| b.is_ascii_digit()) { - Err(ParseError(format!("not a time: `{input}`"))) - } else { - p.parse::().map_err(|_| ParseError("number too large".into())) - } - }; - let parts: Vec<&str> = body.split(['+', ':', ';', '.', ',']).collect(); - let frames = match parts.as_slice() { - [n] => field(n)?, - [feet, fr] if per_ft > 1 => field(feet)?.saturating_mul(per_ft).saturating_add(field(fr)?), - _ => return Err(ParseError(format!("not a time: `{input}`"))), - }; - Ok(match rel { - Some(sign) => current.saturating_add(frames.saturating_mul(sign)), - None => frames, - }) -} - -/// Common audio sample rates offered in sequence settings. -pub const SAMPLE_RATES: [i64; 6] = [32_000, 44_100, 48_000, 88_200, 96_000, 192_000]; - #[cfg(test)] mod tests; diff --git a/crates/timeline/src/time/tests.rs b/crates/timeline/src/time/tests.rs index e6aada244..40bea7624 100644 --- a/crates/timeline/src/time/tests.rs +++ b/crates/timeline/src/time/tests.rs @@ -1,6 +1,10 @@ use super::*; use proptest::prelude::*; +fn clamp_i64(value: i128) -> i64 { + value.clamp(i128::from(i64::MIN), i128::from(i64::MAX)) as i64 +} + #[test] fn empty_time_ranges_never_overlap() { let clip = TimeRange::new(Tick(0), Tick(10)); From f16d99f71c417d1f9247ae17ec53f4a66f6bfb82 Mon Sep 17 00:00:00 2001 From: "Dylan M. Taylor" Date: Fri, 13 Nov 2026 07:57:51 +0000 Subject: [PATCH 5/5] fix time parsing and frame boundary edge cases --- crates/time/README.md | 2 +- crates/time/src/lib.rs | 23 ++++++++++++++- crates/time/src/soundcraft/format.rs | 2 +- crates/time/src/soundcraft/grid.rs | 40 ++++++++++++++++++++++---- crates/time/src/soundcraft/mod.rs | 28 +++++++++++++++++- crates/time/src/soundcraft/timecode.rs | 23 +++++++++++---- 6 files changed, 103 insertions(+), 15 deletions(-) diff --git a/crates/time/README.md b/crates/time/README.md index 0f62fb3b1..9c85b0219 100644 --- a/crates/time/README.md +++ b/crates/time/README.md @@ -35,7 +35,7 @@ assert_eq!(audio_sample, one_sec); Frame rates are represented as exact fractions (`num / den`): ```rust -use craft_time::FrameRate; +use craft_time::{FrameRate, Tick}; // Standard NTSC and PAL rates let ntsc_24 = FrameRate::FPS_23_976; // 24000 / 1001 diff --git a/crates/time/src/lib.rs b/crates/time/src/lib.rs index 6cc780cf9..4a3af58df 100644 --- a/crates/time/src/lib.rs +++ b/crates/time/src/lib.rs @@ -561,7 +561,7 @@ pub fn format_time(t: Tick, rate: FrameRate, drop_frame: bool, display: TimeDisp let abs_ticks = t.0.unsigned_abs(); let total_secs = abs_ticks / (TICKS_PER_SECOND as u64); let rem_ticks = abs_ticks % (TICKS_PER_SECOND as u64); - let rem_samples = Tick(rem_ticks as i64).to_units_floor(sample_rate); + let rem_samples = Tick(rem_ticks as i64).to_units_floor(sample_rate.max(0)); let h = total_secs / 3600; let m = (total_secs / 60) % 60; let s = total_secs % 60; @@ -625,11 +625,22 @@ fn parse_timecode_with_negative_policy( if s.is_empty() { return Err(ParseError("empty timecode".into())); } + if s.len() > 80 { + return Err(ParseError("timecode is too long".into())); + } let (sign_char, body) = match s.as_bytes()[0] { b'+' => (Some('+'), &s[1..]), b'-' => (Some('-'), &s[1..]), _ => (None, s), }; + // Four fields is the complete timecode grammar. Check limits before allocating split vectors. + let separators = body.bytes().filter(|b| matches!(b, b':' | b';' | b'.' | b',')).count(); + if separators > 3 { + return Err(ParseError("too many timecode fields".into())); + } + if separators == 0 && body.len() > if sign_char.is_some() { 19 } else { 8 } { + return Err(ParseError("timecode is too long".into())); + } let parts: Vec<&str> = body.split([':', ';', '.', ',']).collect(); let nums: Vec = if parts.len() == 1 { let digits = parts[0]; @@ -824,6 +835,15 @@ mod tests { } } + #[test] + fn timecode_parser_rejects_oversized_inputs_before_field_collection() { + let too_many_fields = ":".repeat(100_000); + assert!(parse_timecode(&too_many_fields, FrameRate::FPS_30, false, 0).is_err()); + let compact = "1".repeat(9); + assert!(parse_timecode(&compact, FrameRate::FPS_30, false, 0).is_err()); + assert!(parse_timecode_relative("+12345678901234567890", FrameRate::FPS_30, false, 0).is_err()); + } + #[test] fn test_broadcast_frame_rates() { for r in FrameRate::COMMON { @@ -858,6 +878,7 @@ mod tests { assert_eq!(format_time(neg_complex, r, false, TimeDisplay::AudioSamples, 48_000), "-01:01:01:00000"); let zero = Tick::ZERO; + assert_eq!(format_time(Tick(2), r, false, TimeDisplay::AudioSamples, -1), "00:00:00:00000"); assert_eq!(format_time(zero, r, false, TimeDisplay::AudioSamples, 48_000), "00:00:00:00000"); } diff --git a/crates/time/src/soundcraft/format.rs b/crates/time/src/soundcraft/format.rs index 11b588c3f..b16511006 100644 --- a/crates/time/src/soundcraft/format.rs +++ b/crates/time/src/soundcraft/format.rs @@ -76,7 +76,7 @@ pub fn format_length(start: Samples, len: Samples, fmt: TimeFormat, sr: SampleRa let t0 = map.samples_to_ticks(start, sr); let t1 = map.samples_to_ticks(start.saturating_add(len), sr); let m = map.meter_at_tick(t0); - let d = (t1 - t0).max(0); + let d = t1.saturating_sub(t0).max(0); let bars = d / m.ticks_per_bar().max(1); let rem = d % m.ticks_per_bar().max(1); format!("{bars}| {}| {:03}", rem / m.ticks_per_beat().max(1), rem % m.ticks_per_beat().max(1)) diff --git a/crates/time/src/soundcraft/grid.rs b/crates/time/src/soundcraft/grid.rs index 5f32ca6d8..2d40a454b 100644 --- a/crates/time/src/soundcraft/grid.rs +++ b/crates/time/src/soundcraft/grid.rs @@ -430,7 +430,7 @@ impl GridValue { // always four quarter notes and therefore keeps a continuous tick origin. let bb = map.bar_beat_at_tick(t); let bar_tick = map.bar_start_tick(bb.bar); - let origin = if value == NoteValue::Whole { 0 } else { bar_tick }; + let origin = if value == NoteValue::Whole || (value == NoteValue::Bar && dotted) { 0 } else { bar_tick }; let step = Self::note_ticks(value, dotted, triplet, map, origin); let k = (t - origin).div_euclid(step); map.tick_to_samples(origin + k * step, sr) @@ -547,7 +547,14 @@ impl GridValue { } let step = self.step_samples(line, sr, map, rate); let candidate = self.line_at_or_before(line.saturating_add(step), sr, map, rate); - if candidate > line { candidate } else { line.saturating_add(step) } + let next = if candidate > line { candidate } else { line.saturating_add(step) }; + if matches!(self, GridValue::Note { value: NoteValue::Bar, .. }) { + return next; + } + let tick = map.samples_to_ticks(line, sr); + let bar = map.bar_beat_at_tick(tick).bar; + let next_bar = map.tick_to_samples(map.bar_start_tick(bar.saturating_add(1)), sr); + if next_bar > line { next.min(next_bar) } else { next } } /// All grid lines within `[start, end)`, capped at `max` lines. @@ -681,6 +688,27 @@ mod tests { assert_eq!(note(NoteValue::Half).lines(0, 150_000, sr, &map, rate, 10), [0, 48_000, 72_000, 120_000, 144_000]); } + #[test] + fn dotted_bar_grid_keeps_its_spacing_across_bars() { + let (sr, map, rate) = (SampleRate::HZ_48000, TempoMap::default(), FrameRate::Fps25); + let grid = GridValue::Note { value: NoteValue::Bar, dotted: true, triplet: false }; + assert_eq!(grid.line_at_or_before(143_999, sr, &map, rate), 0); + assert_eq!(grid.line_at_or_before(144_000, sr, &map, rate), 144_000); + assert_eq!(grid.next_line(0, sr, &map, rate), 144_000); + } + + #[test] + fn dotted_note_does_not_skip_a_meter_change_boundary() { + let sr = SampleRate::HZ_48000; + let mut map = TempoMap::default(); + map.set_meter(TICKS_PER_QUARTER * 4, 2, 4).unwrap(); + let rate = FrameRate::Fps25; + let grid = GridValue::Note { value: NoteValue::Half, dotted: true, triplet: false }; + assert_eq!(grid.next_line(72_000, sr, &map, rate), 96_000); + assert!(grid.lines(72_000, 120_000, sr, &map, rate, 10).contains(&96_000)); + assert_eq!(grid.snap(90_000, sr, &map, rate), 96_000); + } + #[test] fn seconds_grid_lines_land_on_whole_seconds() { let (sr, map, rate) = (SampleRate::HZ_44100, TempoMap::default(), FrameRate::Fps30); @@ -745,7 +773,8 @@ mod tests { let frame = GridValue::drop_second_frame(seconds, rate); let line = rate.samples_at_frame(frame, sr); assert_eq!(grid.line_at_or_before(line, sr, &map, rate), line, "{rate:?} second {seconds}"); - assert_eq!(grid.line_at_or_before(-line, sr, &map, rate), -line, "{rate:?} second -{seconds}"); + let negative_line = rate.samples_at_frame(-frame, sr); + assert_eq!(grid.line_at_or_before(negative_line, sr, &map, rate), negative_line, "{rate:?} second -{seconds}"); for offset in -2..=rate.nominal() + 2 { let position = rate.samples_at_frame(frame.saturating_add(offset), sr); let before = grid.line_at_or_before(position, sr, &map, rate); @@ -761,8 +790,9 @@ mod tests { let boundary = rate.samples_at_frame(minute_frame, sr); assert_eq!(grid.line_at_or_before(boundary, sr, &map, rate), boundary, "{rate:?} positive minute boundary"); assert_eq!(grid.snap(boundary, sr, &map, rate), boundary, "{rate:?} positive minute snap"); - assert_eq!(grid.line_at_or_before(-boundary, sr, &map, rate), -boundary, "{rate:?} negative minute boundary"); - assert_eq!(grid.snap(-boundary, sr, &map, rate), -boundary, "{rate:?} negative minute snap"); + let negative_boundary = rate.samples_at_frame(-minute_frame, sr); + assert_eq!(grid.line_at_or_before(negative_boundary, sr, &map, rate), negative_boundary, "{rate:?} negative minute boundary"); + assert_eq!(grid.snap(negative_boundary, sr, &map, rate), negative_boundary, "{rate:?} negative minute snap"); } } diff --git a/crates/time/src/soundcraft/mod.rs b/crates/time/src/soundcraft/mod.rs index bd4df3aa9..300909003 100644 --- a/crates/time/src/soundcraft/mod.rs +++ b/crates/time/src/soundcraft/mod.rs @@ -38,8 +38,16 @@ pub enum TimeError { /// Sample rates supported by sessions (Pro Tools offers 44.1 k – 192 k; we accept 8 k – 768 k). #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, serde::Serialize, serde::Deserialize)] +#[serde(try_from = "u32")] pub struct SampleRate(u32); +impl TryFrom for SampleRate { + type Error = TimeError; + fn try_from(hz: u32) -> Result { + Self::new(hz) + } +} + impl SampleRate { pub const HZ_44100: SampleRate = SampleRate(44_100); pub const HZ_48000: SampleRate = SampleRate(48_000); @@ -107,7 +115,7 @@ impl Range { s >= self.start && s < self.end } pub fn overlaps(&self, o: &Range) -> bool { - self.start < o.end && o.start < self.end + !self.is_empty() && !o.is_empty() && self.start < o.end && o.start < self.end } pub fn intersect(&self, o: &Range) -> Option { let s = self.start.max(o.start); @@ -123,6 +131,24 @@ impl Range { mod tests { use super::*; + #[test] + fn sample_rate_serde_validates_nonzero_bounds() { + assert!(serde_json::from_str::("0").is_err()); + assert!(serde_json::from_str::("7999").is_err()); + let rate = SampleRate::new(48_000).unwrap(); + assert_eq!(serde_json::to_string(&rate).unwrap(), "48000"); + assert_eq!(serde_json::from_str::("48000").unwrap(), rate); + } + + #[test] + fn empty_ranges_never_overlap() { + let point = Range::point(5); + let span = Range::new(0, 10); + assert!(!point.overlaps(&span)); + assert!(!span.overlaps(&point)); + assert!(Range::new(0, 5).overlaps(&span)); + } + #[test] fn sample_rate_bounds() { assert!(SampleRate::new(48_000).is_ok()); diff --git a/crates/time/src/soundcraft/timecode.rs b/crates/time/src/soundcraft/timecode.rs index 03eb1cc94..d09458e5c 100644 --- a/crates/time/src/soundcraft/timecode.rs +++ b/crates/time/src/soundcraft/timecode.rs @@ -108,9 +108,12 @@ impl FrameRate { if n == 0 { return 0; } - // Round half away from zero so negative frames mirror positive ones. - let mag = (i128::from(f.unsigned_abs()) * i128::from(sr.hz()) * i128::from(d) + i128::from(n) / 2) / i128::from(n); - let v = if f < 0 { -mag } else { mag }; + // Use the first integer sample whose floored frame lookup is `f`; nearest rounding + // can put fractional-rate frame starts just before their own lookup interval. + let numerator = i128::from(f) * i128::from(sr.hz()) * i128::from(d); + let denominator = i128::from(n); + let floor = numerator.div_euclid(denominator); + let v = floor + i128::from(numerator.rem_euclid(denominator) != 0); i64::try_from(v).unwrap_or(if v < 0 { i64::MIN } else { i64::MAX }) } @@ -194,6 +197,12 @@ impl Timecode { Some(r) => (true, r), None => (false, t), }; + if t.len() > 80 { + return Err(TimeError::Parse("timecode is too long".into(), "timecode")); + } + if t.bytes().filter(|b| matches!(b, b':' | b';' | b'.')).count() > 3 { + return Err(TimeError::Parse(text.to_string(), "timecode")); + } let parts: Vec<&str> = t.split([':', ';', '.']).collect(); if parts.is_empty() || parts.len() > 4 { return Err(TimeError::Parse(text.to_string(), "timecode")); @@ -232,15 +241,16 @@ mod tests { } #[test] - fn negative_timecode_rounds_symmetrically() { + fn fractional_frame_starts_match_floored_lookup_on_both_sides_of_zero() { let sr = SampleRate::HZ_48000; let tc = Timecode::parse("-00:00:01:00").unwrap(); assert_eq!(tc.to_samples(FrameRate::Fps25, sr), -48_000); for r in [FrameRate::Fps25, FrameRate::Fps29_97, FrameRate::Fps29_97Drop, FrameRate::Fps23_976] { - for f in [1i64, 7, 25, 1799, 107_892] { - assert_eq!(r.samples_at_frame(-f, sr), -r.samples_at_frame(f, sr), "{r:?} frame {f}"); + for f in [-107_892i64, -25, -7, -1, 0, 1, 2, 7, 25, 1799, 107_892] { + assert_eq!(r.frames_at(r.samples_at_frame(f, sr), sr), f, "{r:?} frame {f}"); } } + assert_eq!(FrameRate::Fps29_97.frames_at(FrameRate::Fps29_97.samples_at_frame(2, sr), sr), 2); } #[test] @@ -299,6 +309,7 @@ mod tests { assert_eq!((tc.seconds, tc.frames), (3, 4)); assert!(Timecode::parse("a:b").is_err()); assert!(Timecode::parse("1:2:3:4:5").is_err()); + assert!(Timecode::parse(&format!("{}:", "1".repeat(100_000))).is_err()); } #[test]