Browse / github.com/storytold/craft-libs / patch
Add craft-time: exact media timebase and SMPTE timecode
Add craft-time: exact media timebase and SMPTE timecode ### Summary This PR introduces `craft-time`, a standalone crate providing nanosecond-independent, exact rational media time math and SMPTE timecode representations. ### Context & Motivation (refs #2) Creative timeline applications in the Craft suite—specifically **FilmCraft** (NLE video editing), **SoundCraft** (DAW audio sample alignment), and **EffectCraft** (motion graphics compositing)—require exact, drift-free time representation that handles non-integer frame rates (23.976, 29.97, 59.94) without floating-point accumulation errors. ### Implementation Details - **Fixed Sub-Frame Timebase
- Status
- open
- Score
- 0.00
- Adopters
- 0 other operators
- Author
- zachshallbetter (imported from pull request #15, open)
- Base
ae2f796b673f29cf3161f36eb6a8933f0a3fe028- Size
- +1340 −8 in 7 files
- Review
- pending
- Shared
- 2026-10-28
- Id
8b46df251af7ec9620f3a6ad
How it went
No other operator has reported using it yet.
Diff
raw · Cargo.lock Cargo.toml README.md crates/time/Cargo.toml crates/time/README.md crates/time/src/lib.rs crates/time/tests/differential.rs
@@ -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",@@ -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"@@ -39,11 +39,11 @@ craft-<name> = { git = "https://github.com/storytold/craft-libs", rev = "<commit language setting. - [`craft-print`](crates/print/README.md): PDF submission to CUPS and Windows, printer discovery, duplex/copies/colour and driver preferences. PDF generation stays in each app.- - [`craft-quit`](crates/quit/src/lib.rs): native macOS quit, egui close handling and an optional app menu. Unsaved-document decisions and save prompts stay in each app. - [`craft-settings`](crates/settings/README.md): `config.toml` settings storage for every app: platform folders, portable mode and overrides, atomic saves while running, backup, damaged-file recovery and schema migrations. Policy in [docs/settings-storage.md](docs/settings-storage.md).+- [`craft-time`](crates/time/README.md): Exact media time base (`TICKS_PER_SECOND = 254_016_000_000`), rational frame rates, and SMPTE timecode (drop/non-drop frame, 35mm/16mm feet+frames) for FilmCraft, SoundCraft, EffectCraft, and animation timeline consumers. - [`craft-ui`](crates/ui/README.md): SVG catalogs, icon buttons, tabs, pane controls, transactional docking, print settings and theme helpers for egui frontends; assets, themes, localization and command handling stay in each app. Requires Rust 1.95 or later. - [Shared UI component audit](docs/ui-components-audit.md): source inventory, extraction prioritiesnew file mode 100644@@ -0,0 +1,17 @@+[package]+name = "craft-time"+version.workspace = true+edition.workspace = true+license.workspace = true+rust-version.workspace = true+repository.workspace = true+description = "Exact media time, frame rate rational math, and SMPTE timecode for the Craft suite"++[lints]+workspace = true++[dependencies]+serde = { workspace = true }++[dev-dependencies]+proptest = "1.4"new file mode 100644@@ -0,0 +1,110 @@+# Craft Time++`craft-time` provides exact, drift-free media time representation, rational frame-rate mathematics, SMPTE timecode formatting/parsing, and film footage calculations for the Craft suite.++Extracted from the common time engine in **FilmCraft** and **EffectCraft** (refs #2), it is designed for NLEs, motion graphics compositors, audio workstations, and media transcoders.++## Core Concepts++### Exact Ticks (`TICKS_PER_SECOND`)++All media time is tracked as an integer number of clock ticks at:++$$\text{TICKS\_PER\_SECOND} = 254{,}016{,}000{,}000 \text{ ticks/s}$$++This rate was chosen because it divides evenly into every broadcast and cinematic frame rate, as well as every standard professional audio sample rate:++- **Frame Rates**: 23.976, 24, 25, 29.97, 30, 48, 50, 59.94, 60, 119.88, 120 fps.+- **Audio Sample Rates**: 32 kHz, 44.1 kHz, 48 kHz, 88.2 kHz, 96 kHz, 192 kHz.++Edits, clip slicing, frame stepping, and audio/video alignment never suffer cumulative floating-point drift.++```rust+use craft_time::{Tick, TICKS_PER_SECOND};++// Convert from seconds or sample units+let one_sec = Tick::from_seconds_f64(1.0);+assert_eq!(one_sec.0, TICKS_PER_SECOND);++let audio_sample = Tick::from_units(48000, 48000);+assert_eq!(audio_sample, one_sec);+```++### Rational Frame Rates (`FrameRate`)++Frame rates are represented as exact fractions (`num / den`):++```rust+use craft_time::FrameRate;++// Standard NTSC and PAL rates+let ntsc_24 = FrameRate::FPS_23_976; // 24000 / 1001+let pal_25 = FrameRate::FPS_25; // 25 / 1+let ntsc_30 = FrameRate::FPS_29_97; // 30000 / 1001++// Start tick of frame 100+let start_tick = ntsc_30.tick_of(100);++// Frame containing a tick (floored)+let frame = ntsc_30.frame_at(start_tick);+assert_eq!(frame, 100);++// Snap to exact frame boundaries+let snapped = ntsc_30.snap(start_tick + Tick(5));+assert_eq!(snapped, start_tick);+```++### SMPTE Timecode (`format_timecode_frames` / `parse_timecode`)++Full support for SMPTE standard drop-frame (semicolon `;` separator) and non-drop frame (colon `:` separator) timecode:++```rust+use craft_time::{FrameRate, format_timecode_frames, parse_timecode};++let r = FrameRate::FPS_29_97;++// Format frame count to drop-frame timecode+let tc = format_timecode_frames(1800, r, true);+assert_eq!(tc, "00;01;00;02"); // Frames 00 and 01 dropped at minute mark++// Parse user-entered timecode (supports relative offsets like +15 or -1.00)+let parsed = parse_timecode("00;01;00;02", r, true, 0).unwrap();+assert_eq!(parsed, 1800);+```++### Film Footage (`format_feet_frames` / `parse_feet_frames`)++Calculates 35mm (16 frames/foot) and 16mm (40 frames/foot) film footage counts:++```rust+use craft_time::{format_feet_frames, parse_feet_frames};++// 35mm (16 frames per foot)+assert_eq!(format_feet_frames(199, 16), "0012+07");+assert_eq!(parse_feet_frames("12+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));+```++## EffectCraft & FilmCraft Compatibility Differences++`craft-time` unifies time representation across the Craft suite while explicitly documenting application-specific design choices:++1. **After Effects Display Formatting**:+ FilmCraft uses standard broadcast SMPTE zero-padded hours (`00:00:02:15` via `format_timecode_frames`).+ EffectCraft / After Effects displays hours without leading zeroes (`0:00:02:15` via `format_timecode_ae`). Both functions are provided.+2. **Oversized Timecode Saturation**:+ In `craft-time`, `parse_timecode` employs saturating arithmetic (`saturating_mul`, `saturating_add`) when frame calculations exceed 64-bit limits, saturating to `i64::MAX` or `i64::MIN` to keep operations total without unrecoverable errors. In contrast, EffectCraft returns `Err(ParseError("timecode too large"))`.+3. **Degenerate `{0, 0}` Frame Rates**:+ In `craft-time`, `FrameRate::sane()` normalizes non-positive or zero rates (such as `{ num: 0, den: 0 }` or negative denominators) to `FrameRate::FPS_23_976` (nominal timecode base 24), maintaining standard broadcast defaults. In contrast, EffectCraft falls back to a timecode base of 1.+4. **Monotonic Operator Division**:+ Operator division on `Tick` (`Tick / i64`) and `Tick::saturating_div` saturate on overflow (`Tick(i64::MIN) / -1 -> 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)]`.new file mode 100644@@ -0,0 +1,770 @@+#![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<Tick> {+ self.0.checked_add(rhs.0).map(Tick)+ }++ /// Checked subtraction. Returns `None` if overflow occurred.+ pub fn checked_sub(self, rhs: Tick) -> Option<Tick> {+ self.0.checked_sub(rhs.0).map(Tick)+ }++ /// Checked multiplication. Returns `None` if overflow occurred.+ pub fn checked_mul(self, rhs: i64) -> Option<Tick> {+ self.0.checked_mul(rhs).map(Tick)+ }++ /// Checked division. Returns `None` if `rhs == 0` or if overflow occurred (`Tick(i64::MIN) / -1`).+ pub fn checked_div(self, rhs: i64) -> Option<Tick> {+ self.0.checked_div(rhs).map(Tick)+ }++ /// 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<Tick> {+ 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(i64::MIN) / -1 -> Tick(i64::MAX)`).+ pub fn saturating_div(self, rhs: i64) -> Option<Tick> {+ 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<i64> for Tick {+ type Output = Tick;+ fn mul(self, rhs: i64) -> Tick {+ Tick(self.0.saturating_mul(rhs))+ }+}++impl Div<i64> for Tick {+ type Output = Tick;+ fn div(self, rhs: i64) -> Tick {+ // 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().+ if rhs == 0 { Tick::ZERO } else { Tick(self.0.saturating_div(rhs)) }+ }+}++impl Rem<Tick> 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(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) }+ }+}++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<TimeRange> {+ 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 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()),+ }+}++/// 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<i64, ParseError> {+ let s = input.trim();+ if s.is_empty() {+ return Err(ParseError("empty timecode".into()));+ }+ 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();+ let nums: Vec<i64> = 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 sign_char.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::<i64>().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()) {+ return Err(ParseError(format!("bad field `{p}`")));+ }+ p.parse::<i64>().map_err(|_| ParseError(format!("bad field `{p}`")))+ }+ })+ .collect::<Result<_, _>>()?+ };+ 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 && 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)+ };++ 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.+pub fn parse_feet_frames(input: &str, per_foot: i64, current: i64) -> Result<i64, ParseError> {+ let s = input.trim();+ let bad = || ParseError(format!("not feet+frames: `{input}`"));+ let num = |t: &str| -> Result<i64, ParseError> {+ 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::<i64>().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_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] {+ 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));+ }+ }+ }+}new file mode 100644@@ -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::<i64>(),+ b in any::<i64>(),+ d in any::<i64>().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));+ }+ }+ }+}