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 priorities
diff --git a/crates/time/Cargo.toml b/crates/time/Cargo.toml
new 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.md
new 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.rs
new 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.rs
new 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));
+            }
+        }
+    }
+}
