collective

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

diff --git a/Cargo.lock b/Cargo.lock--- 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--- 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--- a/README.md+++ b/README.md@@ -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 prioritiesdiff --git a/crates/time/Cargo.toml b/crates/time/Cargo.tomlnew file mode 100644--- /dev/null+++ b/crates/time/Cargo.toml@@ -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"diff --git a/crates/time/README.md b/crates/time/README.mdnew file mode 100644--- /dev/null+++ b/crates/time/README.md@@ -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)]`.diff --git a/crates/time/src/lib.rs b/crates/time/src/lib.rsnew file mode 100644--- /dev/null+++ b/crates/time/src/lib.rs@@ -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));+            }+        }+    }+}diff --git a/crates/time/tests/differential.rs b/crates/time/tests/differential.rsnew file mode 100644--- /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::<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));+            }+        }+    }+}