Add craft-time: exact media timebase and SMPTE timecode (refs #2)

Implements the shared exact media timebase crate (craft-time) for FilmCraft,
SoundCraft, EffectCraft, and animation timeline consumers:

- Exact Signed Timebase: Tick represents exact media time across [Tick::MIN, Tick::MAX]
  using TICKS_PER_SECOND = 254_016_000_000, supporting exact integer conversions for
  standard film, broadcast, audio, and high-framerate rates.
- Total Arithmetic Contract: All operations are strictly panic-free under
  #![deny(clippy::panic)], providing checked_* methods and monotonic saturating
  division.
- Timecode and Film Footage: Full SMPTE 12M drop-frame and non-drop-frame formatting,
  35mm and 16mm feet+frames, broadcast zero-padded hours, and EffectCraft unpadded hours.
- Differential Testing: 15 test suites incorporating full FilmCraft and EffectCraft
  parity vectors, boundary saturation tests, and proptest monotonicity invariants.
This commit is contained in:
Zach Shallbetter committed 2026-10-18 14:06:33 -07:00
1 parent ae2f796b67
commit d2dc637c77
7 files changed
+1280 -8

No files matched your search

Generated
+126 -7
View File
@@ -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",
+1
View File
@@ -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"
+1 -1
View File
@@ -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
+17
View File
@@ -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"
+110
View File
@@ -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)]`.
+710
View File
@@ -0,0 +1,710 @@
#![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)]
//! Exact media time for the Craft suite.
//!
//! All time is an integer number of [`Tick`]s at [`TICKS_PER_SECOND`] = 254 016 000 000/s. That
//! rate divides evenly into every broadcast frame duration (23.976, 24, 25, 29.97, 30, 48, 50,
//! 59.94, 60, 119.88, 120 fps) and standard audio sample rates (44.1 kHz, 48 kHz, and their
//! multiples), so edits, frame math, audio alignment, and visual effects never drift.
//!
//! Timecode (SMPTE drop/non-drop, frames, feet+frames, audio samples) is a display representation
//! of ticks. Every conversion is total: invalid, zero, or negative rates produce well-defined defaults
//! rather than division-by-zero panics.
use serde::{Deserialize, Serialize};
use std::fmt;
use std::ops::{Add, AddAssign, Div, Mul, Neg, Rem, Sub, SubAssign};
/// Clock ticks per second: 254 016 000 000.
pub const TICKS_PER_SECOND: i64 = 254_016_000_000;
/// A point or duration in time, in ticks.
/// Arithmetic saturates at storage limits; representable results remain exact.
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)]
#[serde(transparent)]
pub struct Tick(pub i64);
impl Tick {
pub const ZERO: Tick = Tick(0);
pub const MAX: Tick = Tick(i64::MAX / 4);
pub const MIN: Tick = Tick(i64::MIN / 4);
/// Seconds to ticks, clamped to [`Tick::MIN`]..=[`Tick::MAX`].
pub fn from_seconds_f64(s: f64) -> Tick {
let t = (s * TICKS_PER_SECOND as f64).round();
Tick((t as i64).clamp(Tick::MIN.0, Tick::MAX.0))
}
pub fn seconds(self) -> f64 {
self.0 as f64 / TICKS_PER_SECOND as f64
}
/// Exact conversion from a count of `units` at `per_second` (e.g. samples at 48 000).
pub fn from_units(units: i64, per_second: i64) -> Tick {
Tick(clamp_i64((units as i128 * TICKS_PER_SECOND as i128).checked_div(per_second as i128).unwrap_or(0)))
}
/// Floor conversion to a count of units at `per_second`.
pub fn to_units_floor(self, per_second: i64) -> i64 {
clamp_i64((self.0 as i128 * per_second as i128).div_euclid(TICKS_PER_SECOND as i128))
}
/// Conversion from a rational timestamp `pts * num / den` seconds (container timebases).
pub fn from_rational(pts: i64, num: i64, den: i64) -> Tick {
if den == 0 {
return Tick::ZERO;
}
let Some(t) = (pts as i128 * num as i128).checked_mul(TICKS_PER_SECOND as i128) else {
return Tick(if (pts < 0) ^ (num < 0) ^ (den < 0) { i64::MIN } else { i64::MAX });
};
Tick(clamp_i64(t.div_euclid(den as i128)))
}
/// Inverse of [`Tick::from_rational`], floored to the timebase.
pub fn to_rational_floor(self, num: i64, den: i64) -> i64 {
clamp_i64((self.0 as i128 * den as i128).checked_div_euclid(num as i128 * TICKS_PER_SECOND as i128).unwrap_or(0))
}
/// Inverse of [`Tick::from_rational`], rounded to the nearest timebase unit (halves up).
pub fn to_rational_round(self, num: i64, den: i64) -> i64 {
let n = self.0 as i128 * den as i128;
let unit = num as i128 * TICKS_PER_SECOND as i128;
let (n, unit) = if unit < 0 { (-n, -unit) } else { (n, unit) };
let (Some(q), Some(r)) = (n.checked_div_euclid(unit), n.checked_rem_euclid(unit)) else { return 0 };
clamp_i64(if r * 2 >= unit { q + 1 } else { q })
}
pub fn abs(self) -> Tick {
Tick(self.0.saturating_abs())
}
pub fn signum(self) -> i64 {
self.0.signum()
}
pub fn min(self, o: Tick) -> Tick {
Tick(self.0.min(o.0))
}
pub fn max(self, o: Tick) -> Tick {
Tick(self.0.max(o.0))
}
pub fn clamp(self, lo: Tick, hi: Tick) -> Tick {
Tick(self.0.max(lo.0).min(hi.0))
}
/// Scale by a rational factor (`num/den`), flooring.
pub fn mul_ratio(self, num: i64, den: i64) -> Tick {
Tick(clamp_i64((self.0 as i128 * num as i128).checked_div_euclid(den as i128).unwrap_or(0)))
}
/// Divide by a rational factor (`num/den`), flooring.
pub fn div_ratio(self, num: i64, den: i64) -> Tick {
self.mul_ratio(den, num)
}
/// Checked addition. Returns `None` if overflow occurred.
pub fn checked_add(self, rhs: Tick) -> Option<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::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::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::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::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::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 secs = t.0.div_euclid(TICKS_PER_SECOND);
let rem = Tick(t.0.rem_euclid(TICKS_PER_SECOND)).to_units_floor(sample_rate);
format!("{:02}:{:02}:{:02}:{rem:05}", secs / 3600, (secs / 60) % 60, secs % 60)
}
TimeDisplay::Seconds => format!("{:.3}", t.seconds()),
}
}
/// Format a frame count as Feet + Frames (film footage counting).
pub fn format_feet_frames(frame: i64, per_foot: i64) -> String {
let pf = per_foot.max(1);
let sign = if frame < 0 { "-" } else { "" };
let a = frame.unsigned_abs();
let pf = pf as u64;
let w = if pf > 100 { 3 } else { 2 };
format!("{sign}{:04}+{:0w$}", a / pf, a % pf)
}
/// Error from timecode parsing.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ParseError(pub String);
impl fmt::Display for ParseError {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.write_str(&self.0)
}
}
impl std::error::Error for ParseError {}
/// Parse user-typed timecode into a frame count.
///
/// Supports standard formats (`HH:MM:SS:FF`, `HH;MM;SS;FF`), short entry (`1000` -> `00:00:10:00`),
/// and relative offsets (`+15`, `-1.00`).
///
/// ### Application Compatibility Note
/// For oversized timecodes whose frame calculations exceed 64-bit limits, `craft-time`
/// saturates to `i64::MAX` (or `i64::MIN`) to keep calculations total without errors.
/// In contrast, EffectCraft returns `Err(ParseError("timecode too large"))`.
pub fn parse_timecode(input: &str, rate: FrameRate, drop_frame: bool, current: i64) -> Result<i64, ParseError> {
let s = input.trim();
if s.is_empty() {
return Err(ParseError("empty timecode".into()));
}
let (rel, body) = match s.as_bytes()[0] {
b'+' => (Some(1), &s[1..]),
b'-' => (Some(-1), &s[1..]),
_ => (None, s),
};
let parts: Vec<&str> = body.split([':', ';', '.', ',']).collect();
let nums: Vec<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 rel.is_some() {
vec![digits.parse().map_err(|_| ParseError("number too large".into()))?]
} else {
let mut v = Vec::new();
let b = digits.as_bytes();
let mut end = b.len();
while end > 0 {
let start = end.saturating_sub(2);
v.push(digits[start..end].parse::<i64>().unwrap_or(0));
end = start;
}
v.reverse();
v
}
} else {
parts
.iter()
.map(|p| if p.is_empty() { Ok(0) } else { 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 && rel.is_some() {
fr
} else if drop_frame && rate.supports_drop_frame() && m < 60 && sec < 60 && fr < base {
fields_to_frames(h, m, sec, fr, rate, true)
} else {
h.saturating_mul(3600).saturating_add(m.saturating_mul(60)).saturating_add(sec).saturating_mul(base).saturating_add(fr)
};
Ok(match rel {
Some(sign) => current.saturating_add(frames.saturating_mul(sign)),
None => frames,
})
}
/// Parse a Feet + Frames field into a frame count.
pub fn parse_feet_frames(input: &str, per_foot: i64, current: i64) -> Result<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_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));
}
}
}
}
+315
View File
@@ -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));
}
}
}
}