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)); + } + } + } +}