Merge pull request #1164 from storytold/codex/craft-time-soundcraft

craft-time: preserve SoundCraft sample-time APIs
This commit is contained in:
Dylan M. Taylor authored and GitHub committed 2026-11-13 04:10:26 -05:00
commit 915fc54458
15 files changed
+3608 -616

No files matched your search

Generated
+11
View File
@@ -2310,10 +2310,21 @@ dependencies = [
"thiserror 2.0.21",
]
[[package]]
name = "craft-time"
version = "0.1.0"
dependencies = [
"proptest",
"serde",
"serde_json",
"thiserror 2.0.21",
]
[[package]]
name = "craft-timeline"
version = "0.1.0"
dependencies = [
"craft-time",
"craft-xml",
"proptest",
"serde",
+1
View File
@@ -113,6 +113,7 @@ craft-raw = { path = "crates/raw" }
craft-sheet = { path = "crates/sheet" }
craft-sketch = { path = "crates/sketch" }
craft-settings = { path = "crates/settings" }
craft-time = { path = "crates/time" }
craft-silhouette-studio = { path = "crates/silhouette-studio" }
craft-svg = { path = "crates/svg" }
craft-stencilcraft-format = { path = "crates/stencilcraft-format" }
+1
View File
@@ -203,6 +203,7 @@ See the [release notes](CHANGELOG.md) for included functionality and known limit
decoder is bounded and returns its partial output on damage. From PdfCraft; a PDF filter chain
is built on it in `craft-pdf`.
- [`craft-timeline`](crates/timeline/README.md): editorial timeline interchange: exact media time and SMPTE timecode, import/export reports, media path and `file://` URL handling, Avid Log Exchange (ALE) and CMX 3600 EDL documents, format detection. From FilmCraft.
- [`craft-time`](crates/time/README.md): exact rational ticks and SMPTE time across media, with a `soundcraft` compatibility module retaining SoundCraft's sample-index timebase, tempo maps, grids and session serialization semantics.
- [`craft-metadata`](crates/metadata/README.md): photo metadata, from LightCraft: EXIF (read and write,
maker-note lens and focus point, every tag by name), IPTC-IIM, XMP packets (read, write, and a merge
that keeps what another program wrote), GPX track logs, and the blocks that carry them in JPEG, PNG,
+19
View File
@@ -0,0 +1,19 @@
[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 }
thiserror.workspace = true
[dev-dependencies]
serde_json = { workspace = true }
proptest = "1.4"
+119
View File
@@ -0,0 +1,119 @@
# 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, Tick};
// 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));
```
## SoundCraft Sample-Time Compatibility
The `soundcraft` module carries SoundCraft's existing sample-index clock (`i64` samples at the
session sample rate), `SampleRate`, `TempoMap`, grid calculations and five timebase parsers and
formatters. It keeps the serialized representation and existing saturation, sanitization and
rounding behavior intact while the rest of the suite can use exact `Tick` values. SoundCraft's
session model imports these types from `craft_time::soundcraft`; the APIs in that module retain
the original SoundCraft names and meanings.
## 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)]`.
+918
View File
@@ -0,0 +1,918 @@
#![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};
/// SoundCraft's sample-index, tempo-map, grid, and display-time API, retained verbatim for
/// session-format compatibility while sharing the broader Craft time primitives above.
pub mod soundcraft;
/// 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 {
Tick((s * TICKS_PER_SECOND as f64).round() as i64)
}
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.is_empty() && !o.is_empty() && 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)
}
/// Closest standard rate when `fps` is within `tolerance` of it, otherwise the nearest
/// 1/1000 rate. This retains the timeline API's coarse-timebase compatibility behavior.
pub fn from_f64_within(fps: f64, tolerance: f64) -> Self {
let mut best: Option<(f64, FrameRate)> = None;
for rate in Self::COMMON {
let off = (rate.as_f64() - fps).abs();
if off <= tolerance * rate.as_f64() && best.is_none_or(|(closest, _)| off < closest) {
best = Some((off, rate));
}
}
best.map_or_else(|| Self::from_f64(fps), |(_, rate)| rate)
}
/// 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))
}
/// Frames from `start` to `end`, counted between their frame indices.
pub fn frames_between(self, start: Tick, end: Tick) -> i64 {
self.frame_at(end).saturating_sub(self.frame_at(start))
}
/// 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];
/// The video display formats (Sequence Settings ▸ Display Format, the burn-in's Time Format).
pub const VIDEO: [TimeDisplay; 4] = [TimeDisplay::Timecode, TimeDisplay::Feet35, TimeDisplay::Feet16, TimeDisplay::Frames];
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",
}
}
/// Stable id used by commands and the UI.
pub fn id(self) -> &'static str {
match self {
TimeDisplay::Timecode => "timecode",
TimeDisplay::Frames => "frames",
TimeDisplay::Feet16 => "feet16",
TimeDisplay::Feet35 => "feet35",
TimeDisplay::AudioSamples => "samples",
TimeDisplay::Seconds => "seconds",
}
}
/// The display with this [`id`](Self::id), ignoring case.
pub fn from_id(id: &str) -> Option<TimeDisplay> {
TimeDisplay::ALL.into_iter().find(|display| display.id().eq_ignore_ascii_case(id))
}
/// Frames per foot for the Feet + Frames displays.
pub fn frames_per_foot(self) -> Option<i64> {
match self {
TimeDisplay::Feet35 => Some(16),
TimeDisplay::Feet16 => Some(40),
_ => None,
}
}
}
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 => format_feet_frames(frame, display.frames_per_foot().unwrap_or(16)),
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 = Tick(rem_ticks as i64).to_units_floor(sample_rate.max(0));
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 => {
let seconds = format!("{:.3}", t.seconds());
if seconds == "-0.000" { "0.000".into() } else { seconds }
}
}
}
/// Format a frame count as Feet + Frames (film footage counting).
pub fn format_feet_frames(frame: i64, per_foot: i64) -> String {
let per_foot = per_foot.max(1);
let sign = if frame < 0 { "-" } else { "" };
let frames = frame.unsigned_abs();
let per_foot = per_foot as u64;
let width = if per_foot > 100 { 3 } else { 2 };
format!("{sign}{:04}+{:0width$}", frames / per_foot, frames % per_foot)
}
/// 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> {
parse_timecode_with_negative_policy(input, rate, drop_frame, current, true)
}
/// Parse timecode with the relative-negative policy used by the historical timeline API.
/// A leading minus offsets from `current`, including a complete four-field timecode.
pub fn parse_timecode_relative(input: &str, rate: FrameRate, drop_frame: bool, current: i64) -> Result<i64, ParseError> {
parse_timecode_with_negative_policy(input, rate, drop_frame, current, false)
}
fn parse_timecode_with_negative_policy(
input: &str,
rate: FrameRate,
drop_frame: bool,
current: i64,
full_negative_is_absolute: bool,
) -> Result<i64, ParseError> {
let s = input.trim();
if s.is_empty() {
return Err(ParseError("empty timecode".into()));
}
if s.len() > 80 {
return Err(ParseError("timecode is too long".into()));
}
let (sign_char, body) = match s.as_bytes()[0] {
b'+' => (Some('+'), &s[1..]),
b'-' => (Some('-'), &s[1..]),
_ => (None, s),
};
// Four fields is the complete timecode grammar. Check limits before allocating split vectors.
let separators = body.bytes().filter(|b| matches!(b, b':' | b';' | b'.' | b',')).count();
if separators > 3 {
return Err(ParseError("too many timecode fields".into()));
}
if separators == 0 && body.len() > if sign_char.is_some() { 19 } else { 8 } {
return Err(ParseError("timecode is too long".into()));
}
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() {
let (sec, fr) = (sec.saturating_add(fr / base), fr % base);
let (m, sec) = (m.saturating_add(sec / 60), sec % 60);
let (h, m) = (h.saturating_add(m / 60), m % 60);
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('-') if full_negative_is_absolute && nums.len() == 4 => Ok(-frames),
Some('-') => {
// A full 4-field timecode with leading '-' represents an absolute negative timecode (e.g. preroll).
// The timeline compatibility path treats every leading minus as an offset instead.
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),
})
}
}
}
/// Parse text using a [`TimeDisplay`] representation.
pub fn parse_time_display(input: &str, rate: FrameRate, drop_frame: bool, display: TimeDisplay, current: i64) -> Result<i64, ParseError> {
parse_time_display_with_negative_policy(input, rate, drop_frame, display, current, true)
}
/// Parse text using a [`TimeDisplay`] representation and the historical timeline relative-minus rule.
pub fn parse_time_display_relative(input: &str, rate: FrameRate, drop_frame: bool, display: TimeDisplay, current: i64) -> Result<i64, ParseError> {
parse_time_display_with_negative_policy(input, rate, drop_frame, display, current, false)
}
fn parse_time_display_with_negative_policy(
input: &str,
rate: FrameRate,
drop_frame: bool,
display: TimeDisplay,
current: i64,
full_negative_is_absolute: bool,
) -> Result<i64, ParseError> {
let per_foot = match display {
TimeDisplay::Frames => 1,
TimeDisplay::Feet35 | TimeDisplay::Feet16 => display.frames_per_foot().unwrap_or(16),
_ => {
return if full_negative_is_absolute {
parse_timecode(input, rate, drop_frame, current)
} else {
parse_timecode_relative(input, rate, drop_frame, current)
};
}
};
let text = input.trim();
if text.is_empty() {
return Err(ParseError("empty time".into()));
}
let (relative, body) = match text.as_bytes()[0] {
b'+' => (Some(1), &text[1..]),
b'-' => (Some(-1), &text[1..]),
_ => (None, text),
};
let field = |part: &str| -> Result<i64, ParseError> {
if part.is_empty() {
Ok(0)
} else if !part.bytes().all(|byte| byte.is_ascii_digit()) {
Err(ParseError(format!("not a time: `{input}`")))
} else {
part.parse::<i64>().map_err(|_| ParseError("number too large".into()))
}
};
let parts: Vec<&str> = body.split(['+', ':', ';', '.', ',']).collect();
let frames = match parts.as_slice() {
[number] => field(number)?,
[feet, frames] if per_foot > 1 => field(feet)?.saturating_mul(per_foot).saturating_add(field(frames)?),
_ => return Err(ParseError(format!("not a time: `{input}`"))),
};
Ok(match relative {
Some(sign) => current.saturating_add(frames.saturating_mul(sign)),
None => frames,
})
}
/// 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 timecode_parser_rejects_oversized_inputs_before_field_collection() {
let too_many_fields = ":".repeat(100_000);
assert!(parse_timecode(&too_many_fields, FrameRate::FPS_30, false, 0).is_err());
let compact = "1".repeat(9);
assert!(parse_timecode(&compact, FrameRate::FPS_30, false, 0).is_err());
assert!(parse_timecode_relative("+12345678901234567890", FrameRate::FPS_30, false, 0).is_err());
}
#[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(Tick(2), r, false, TimeDisplay::AudioSamples, -1), "00:00:00:00000");
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));
}
}
}
}
+241
View File
@@ -0,0 +1,241 @@
//! Formatting and parsing positions in the five timebases.
use super::{BarBeat, FrameRate, SampleRate, Samples, TempoMap, TimeError, Timecode, timecode::feet_frames};
/// The counters' and rulers' timebases.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Default, serde::Serialize, serde::Deserialize)]
pub enum TimeFormat {
BarsBeats,
#[default]
MinSecs,
Timecode,
FeetFrames,
Samples,
}
impl TimeFormat {
pub const ALL: [TimeFormat; 5] = [TimeFormat::BarsBeats, TimeFormat::MinSecs, TimeFormat::Timecode, TimeFormat::FeetFrames, TimeFormat::Samples];
pub fn label(self) -> &'static str {
match self {
TimeFormat::BarsBeats => "Bars|Beats",
TimeFormat::MinSecs => "Min:Secs",
TimeFormat::Timecode => "Timecode",
TimeFormat::FeetFrames => "Feet+Frames",
TimeFormat::Samples => "Samples",
}
}
pub fn id(self) -> &'static str {
match self {
TimeFormat::BarsBeats => "bars_beats",
TimeFormat::MinSecs => "min_secs",
TimeFormat::Timecode => "timecode",
TimeFormat::FeetFrames => "feet_frames",
TimeFormat::Samples => "samples",
}
}
pub fn from_id(id: &str) -> Option<TimeFormat> {
TimeFormat::ALL.into_iter().find(|f| f.id() == id || f.label().eq_ignore_ascii_case(id))
}
}
fn min_secs(s: Samples, sr: SampleRate) -> String {
let neg = s < 0;
// Integer maths: a float `seconds * 1000` lands just below exact millisecond positions.
let ms_total = i64::try_from(i128::from(s).abs() * 1000 / i128::from(sr.hz().max(1))).unwrap_or(i64::MAX);
let mins = ms_total / 60_000;
let secs = (ms_total / 1000) % 60;
let ms = ms_total % 1000;
format!("{}{mins}:{secs:02}.{ms:03}", if neg { "-" } else { "" })
}
/// Format an absolute position. `tc_start` is the timecode of sample 0 in samples, `ff_start` the
/// feet+frames reading of sample 0 in frames.
pub fn format_position(s: Samples, fmt: TimeFormat, sr: SampleRate, map: &TempoMap, rate: FrameRate, tc_start: Samples, ff_start: i64) -> String {
match fmt {
TimeFormat::Samples => s.to_string(),
TimeFormat::MinSecs => min_secs(s, sr),
TimeFormat::Timecode => Timecode::from_samples(s.saturating_add(tc_start), rate, sr).format(rate),
TimeFormat::FeetFrames => {
let (ft, fr) = feet_frames(s, rate, sr, ff_start);
format!("{ft}+{fr:02}")
}
TimeFormat::BarsBeats => {
let bb = map.bar_beat_at(s, sr);
format!("{}| {}| {:03}", bb.bar, bb.beat, bb.tick)
}
}
}
/// Format a length starting at `start` (Bars|Beats lengths count from 0|0|000).
pub fn format_length(start: Samples, len: Samples, fmt: TimeFormat, sr: SampleRate, map: &TempoMap, rate: FrameRate) -> String {
match fmt {
TimeFormat::BarsBeats => {
let t0 = map.samples_to_ticks(start, sr);
let t1 = map.samples_to_ticks(start.saturating_add(len), sr);
let m = map.meter_at_tick(t0);
let d = t1.saturating_sub(t0).max(0);
let bars = d / m.ticks_per_bar().max(1);
let rem = d % m.ticks_per_bar().max(1);
format!("{bars}| {}| {:03}", rem / m.ticks_per_beat().max(1), rem % m.ticks_per_beat().max(1))
}
TimeFormat::Timecode => Timecode::from_samples(len, rate, sr).format(rate),
other => format_position(len, other, sr, map, rate, 0, 0),
}
}
/// Parse a position typed into a counter or dialog.
pub fn parse_position(
text: &str,
fmt: TimeFormat,
sr: SampleRate,
map: &TempoMap,
rate: FrameRate,
tc_start: Samples,
ff_start: i64,
) -> Result<Samples, TimeError> {
let t = text.trim();
let err = || TimeError::Parse(t.to_string(), fmt.label());
match fmt {
TimeFormat::Samples => t.replace([',', '_'], "").parse::<i64>().map_err(|_| err()),
TimeFormat::MinSecs => {
let (neg, body) = match t.strip_prefix('-') {
Some(r) => (true, r),
None => (false, t),
};
let (m, s) = match body.split_once(':') {
Some((m, s)) => (m.trim().parse::<f64>().map_err(|_| err())?, s.trim().parse::<f64>().map_err(|_| err())?),
None => (0.0, body.parse::<f64>().map_err(|_| err())?),
};
if !m.is_finite() || !s.is_finite() || m < 0.0 || s < 0.0 {
return Err(err());
}
let v = sr.samples(m * 60.0 + s);
Ok(if neg { -v } else { v })
}
TimeFormat::Timecode => {
let tc = Timecode::parse(t)?;
if tc.minutes >= 60 || tc.seconds >= 60 || tc.frames >= rate.nominal().max(1) {
return Err(err());
}
if rate.is_drop() && tc.seconds == 0 && tc.minutes % 10 != 0 {
let dropped = if rate.nominal() >= 60 { 4 } else { 2 };
if tc.frames < dropped {
return Err(err());
}
}
Ok(tc.to_samples(rate, sr).saturating_sub(tc_start))
}
TimeFormat::FeetFrames => {
let (ft, fr) = t.split_once('+').ok_or_else(err)?;
let ft: i64 = ft.trim().parse().map_err(|_| err())?;
let fr: i64 = fr.trim().parse().map_err(|_| err())?;
Ok(rate.samples_at_frame(ft.saturating_mul(16).saturating_add(fr).saturating_sub(ff_start), sr))
}
TimeFormat::BarsBeats => {
let parts: Vec<i64> =
t.split(['|', '.', ' ']).filter(|p| !p.is_empty()).map(str::parse::<i64>).collect::<Result<_, _>>().map_err(|_| err())?;
let bb = BarBeat {
bar: *parts.first().ok_or_else(err)?,
beat: parts.get(1).copied().unwrap_or(1).max(1),
tick: parts.get(2).copied().unwrap_or(0).max(0),
};
Ok(map.samples_at_bar_beat(bb, sr))
}
}
}
#[cfg(test)]
mod tests {
use super::*;
fn ctx() -> (SampleRate, TempoMap, FrameRate) {
(SampleRate::HZ_48000, TempoMap::default(), FrameRate::Fps30)
}
#[test]
fn huge_typed_positions_parse_without_overflowing() {
let (sr, map, rate) = ctx();
for (text, fmt) in [
("999999999999999999:0", TimeFormat::Timecode),
("100000000000000:00:00:00", TimeFormat::Timecode),
("-9223372036854775808", TimeFormat::BarsBeats),
] {
let _ = parse_position(text, fmt, sr, &map, rate, 0, 0);
}
}
#[test]
fn formats_like_pro_tools_counters() {
let (sr, map, rate) = ctx();
assert_eq!(format_position(0, TimeFormat::MinSecs, sr, &map, rate, 0, 0), "0:00.000");
assert_eq!(format_position(48_000 * 75 + 24_000, TimeFormat::MinSecs, sr, &map, rate, 0, 0), "1:15.500");
assert_eq!(format_position(0, TimeFormat::BarsBeats, sr, &map, rate, 0, 0), "1| 1| 000");
assert_eq!(format_position(48_000, TimeFormat::Timecode, sr, &map, rate, 0, 0), "00:00:01:00");
assert_eq!(format_position(12345, TimeFormat::Samples, sr, &map, rate, 0, 0), "12345");
}
#[test]
fn minimum_bar_parses_without_overflow() {
let (sr, map, rate) = ctx();
let p = |t: &str| parse_position(t, TimeFormat::BarsBeats, sr, &map, rate, 0, 0);
assert!(p("-9223372036854775808|01|000").is_ok_and(|s| s < 0));
assert_eq!(p("2|3|000").ok(), Some(144_000));
assert_eq!(p("0|1|000").ok(), Some(-96_000));
}
#[test]
fn parse_round_trips() {
let (sr, map, rate) = ctx();
for fmt in TimeFormat::ALL {
for s in [0i64, 48_000, 96_000 * 3, 1_440_000] {
let text = format_position(s, fmt, sr, &map, rate, 0, 0);
let back = parse_position(&text, fmt, sr, &map, rate, 0, 0).unwrap();
assert!((back - s).abs() <= 48, "{fmt:?} {s} → {text} → {back}");
}
}
}
#[test]
fn parse_round_trips_timecode_negative() {
let (sr, map, _) = ctx();
let rate = FrameRate::Fps25;
for fmt in [TimeFormat::Timecode, TimeFormat::FeetFrames] {
for s in [-48_000i64, -96_000 * 3, -1_440_000] {
let text = format_position(s, fmt, sr, &map, rate, 0, 0);
let back = parse_position(&text, fmt, sr, &map, rate, 0, 0).unwrap();
assert_eq!(back, s, "{fmt:?} {s} → {text} → {back}");
}
}
}
#[test]
fn parse_rejects_garbage() {
let (sr, map, rate) = ctx();
assert!(parse_position("abc", TimeFormat::Samples, sr, &map, rate, 0, 0).is_err());
assert!(parse_position("1:x", TimeFormat::MinSecs, sr, &map, rate, 0, 0).is_err());
assert!(parse_position("", TimeFormat::BarsBeats, sr, &map, rate, 0, 0).is_err());
}
#[test]
fn drop_frame_skipped_labels_are_rejected() {
let (sr, map, _) = ctx();
for (rate, skipped) in [
(FrameRate::Fps29_97Drop, &["00:01:00;00", "00:01:00;01"][..]),
(FrameRate::Fps59_94Drop, &["00:01:00;00", "00:01:00;01", "00:01:00;02", "00:01:00;03"][..]),
] {
for text in skipped {
assert!(parse_position(text, TimeFormat::Timecode, sr, &map, rate, 0, 0).is_err(), "accepted skipped label {text} at {rate:?}");
}
assert!(parse_position("00:10:00;00", TimeFormat::Timecode, sr, &map, rate, 0, 0).is_ok());
}
}
#[test]
fn bar_lengths() {
let (sr, map, rate) = ctx();
assert_eq!(format_length(0, 96_000 + 24_000, TimeFormat::BarsBeats, sr, &map, rate), "1| 1| 000");
}
}
+839
View File
@@ -0,0 +1,839 @@
//! Grid and Nudge values: musical or absolute step sizes, grid lines and snapping.
use super::{FrameRate, SampleRate, Samples, TICKS_PER_QUARTER, TempoMap, Timecode, to_samples};
/// Note values for Bars|Beats grids.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, serde::Serialize, serde::Deserialize)]
pub enum NoteValue {
Bar,
Whole,
Half,
Quarter,
Eighth,
Sixteenth,
ThirtySecond,
SixtyFourth,
}
impl NoteValue {
/// Every musical increment in menu order, from largest to smallest.
pub const ALL: [NoteValue; 8] = [
NoteValue::Bar,
NoteValue::Whole,
NoteValue::Half,
NoteValue::Quarter,
NoteValue::Eighth,
NoteValue::Sixteenth,
NoteValue::ThirtySecond,
NoteValue::SixtyFourth,
];
/// Returns the nominal tick length; bars are adjusted for the active meter when used.
pub fn ticks(self) -> i64 {
match self {
NoteValue::Bar => TICKS_PER_QUARTER * 4,
NoteValue::Whole => TICKS_PER_QUARTER * 4,
NoteValue::Half => TICKS_PER_QUARTER * 2,
NoteValue::Quarter => TICKS_PER_QUARTER,
NoteValue::Eighth => TICKS_PER_QUARTER / 2,
NoteValue::Sixteenth => TICKS_PER_QUARTER / 4,
NoteValue::ThirtySecond => TICKS_PER_QUARTER / 8,
NoteValue::SixtyFourth => TICKS_PER_QUARTER / 16,
}
}
/// Returns the menu label for this musical increment.
pub fn label(self) -> &'static str {
match self {
NoteValue::Bar => "1 bar",
NoteValue::Whole => "Whole note",
NoteValue::Half => "1/2 note",
NoteValue::Quarter => "1/4 note",
NoteValue::Eighth => "1/8 note",
NoteValue::Sixteenth => "1/16 note",
NoteValue::ThirtySecond => "1/32 note",
NoteValue::SixtyFourth => "1/64 note",
}
}
}
/// The time scale used by a Grid or Nudge value.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, serde::Serialize, serde::Deserialize)]
pub enum GridScale {
BarsBeats,
MinSecs,
Timecode,
FeetFrames,
Samples,
ClipsMarkers,
}
impl GridScale {
/// Numeric time scales shared by Grid and Nudge.
pub const TIME: [GridScale; 5] = [GridScale::BarsBeats, GridScale::MinSecs, GridScale::Timecode, GridScale::FeetFrames, GridScale::Samples];
/// Returns the user-facing scale label.
pub fn label(self) -> &'static str {
match self {
GridScale::BarsBeats => "Bars|Beats",
GridScale::MinSecs => "Min:Secs",
GridScale::Timecode => "Timecode",
GridScale::FeetFrames => "Feet+Frames",
GridScale::Samples => "Samples",
GridScale::ClipsMarkers => "Clips/Markers",
}
}
/// Returns the stable command and session identifier.
pub fn id(self) -> &'static str {
match self {
GridScale::BarsBeats => "bars_beats",
GridScale::MinSecs => "min_secs",
GridScale::Timecode => "timecode",
GridScale::FeetFrames => "feet_frames",
GridScale::Samples => "samples",
GridScale::ClipsMarkers => "clips_markers",
}
}
/// Parses a stable identifier or user-facing label.
pub fn from_id(id: &str) -> Option<GridScale> {
Self::TIME.into_iter().chain([GridScale::ClipsMarkers]).find(|scale| scale.id() == id || scale.label().eq_ignore_ascii_case(id))
}
}
/// Timecode Grid/Nudge increments, ordered independently of the session frame rate.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, serde::Serialize, serde::Deserialize)]
pub enum TimecodeValue {
Second,
/// Five physical frames, independent of skipped drop-frame labels.
FiveFrames,
Frame,
HalfFrame,
QuarterFrame,
SubFrame,
}
impl TimecodeValue {
/// Every Timecode increment in menu order, from largest to smallest.
pub const ALL: [TimecodeValue; 6] = [
TimecodeValue::Second,
TimecodeValue::FiveFrames,
TimecodeValue::Frame,
TimecodeValue::HalfFrame,
TimecodeValue::QuarterFrame,
TimecodeValue::SubFrame,
];
/// Returns the user-facing increment label.
pub fn label(self) -> &'static str {
match self {
TimecodeValue::Second => "1 second",
TimecodeValue::FiveFrames => "5 frames",
TimecodeValue::Frame => "1 frame",
TimecodeValue::HalfFrame => "1/2 frame",
TimecodeValue::QuarterFrame => "1/4 frame",
TimecodeValue::SubFrame => "1 subframe",
}
}
/// Returns the stable command and session identifier.
pub fn id(self) -> &'static str {
match self {
TimecodeValue::Second => "second",
TimecodeValue::FiveFrames => "5_frames",
TimecodeValue::Frame => "frame",
TimecodeValue::HalfFrame => "half_frame",
TimecodeValue::QuarterFrame => "quarter_frame",
TimecodeValue::SubFrame => "subframe",
}
}
/// Parses a stable identifier or user-facing label.
pub fn from_id(id: &str) -> Option<TimecodeValue> {
Self::ALL.into_iter().find(|value| value.id() == id || value.label().eq_ignore_ascii_case(id))
}
/// Returns the increment length in frames at the session rate.
fn frames(self, rate: FrameRate) -> f64 {
match self {
TimecodeValue::Second => rate.nominal() as f64,
TimecodeValue::FiveFrames => 5.0,
TimecodeValue::Frame => 1.0,
TimecodeValue::HalfFrame => 0.5,
TimecodeValue::QuarterFrame => 0.25,
TimecodeValue::SubFrame => 0.01,
}
}
}
/// Feet+Frames Grid/Nudge increments in physical frames, independent of drop-frame label skips.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, serde::Serialize, serde::Deserialize)]
pub enum FeetFramesValue {
Foot,
Frame,
QuarterFrame,
SubFrame,
}
impl FeetFramesValue {
/// Every Feet+Frames increment in menu order, from largest to smallest.
pub const ALL: [FeetFramesValue; 4] = [FeetFramesValue::Foot, FeetFramesValue::Frame, FeetFramesValue::QuarterFrame, FeetFramesValue::SubFrame];
/// Returns the user-facing increment label.
pub fn label(self) -> &'static str {
match self {
FeetFramesValue::Foot => "1 foot",
FeetFramesValue::Frame => "1 frame",
FeetFramesValue::QuarterFrame => "1/4 frame",
FeetFramesValue::SubFrame => "1 subframe",
}
}
/// Returns the stable command and session identifier.
pub fn id(self) -> &'static str {
match self {
FeetFramesValue::Foot => "foot",
FeetFramesValue::Frame => "frame",
FeetFramesValue::QuarterFrame => "quarter_frame",
FeetFramesValue::SubFrame => "subframe",
}
}
/// Parses a stable identifier or user-facing label.
pub fn from_id(id: &str) -> Option<FeetFramesValue> {
Self::ALL.into_iter().find(|value| value.id() == id || value.label().eq_ignore_ascii_case(id))
}
/// Returns the increment length in frames.
fn frames(self) -> f64 {
let (num, den) = self.frame_ratio();
num as f64 / den as f64
}
/// Exact numerator and denominator of the increment in physical frames.
fn frame_ratio(self) -> (i128, i128) {
match self {
Self::Foot => (16, 1),
Self::Frame => (1, 1),
Self::QuarterFrame => (1, 4),
Self::SubFrame => (1, 100),
}
}
/// Apply the counter origin in frames before rounding once to the nearest sample.
fn sample_line(self, index: i128, sr: SampleRate, rate: FrameRate, origin: i64) -> Samples {
let (num, den) = self.frame_ratio();
let (rate_num, rate_den) = rate.rational();
let frames = index * num - i128::from(origin) * den;
let samples = frames * i128::from(sr.hz().max(1)) * i128::from(rate_den);
let divisor = den * i128::from(rate_num);
let magnitude = (samples.abs() + divisor / 2) / divisor;
let rounded = if samples < 0 { -magnitude } else { magnitude };
i64::try_from(rounded).unwrap_or(if rounded < 0 { i64::MIN } else { i64::MAX })
}
/// Find a line index using exact frame arithmetic, allowing for whole-sample rounding.
fn line_index(self, position: Samples, sr: SampleRate, rate: FrameRate, origin: i64) -> i128 {
let (num, den) = self.frame_ratio();
let (rate_num, rate_den) = rate.rational();
let scale = i128::from(sr.hz().max(1)) * i128::from(rate_den);
let frames = i128::from(position) * i128::from(rate_num) + i128::from(origin) * scale;
let mut index = (frames * den).div_euclid(scale * num);
if self.sample_line(index + 1, sr, rate, origin) <= position {
index += 1;
}
index
}
/// The Feet+Frames line at or before a sample, relative to the counter's frame origin.
pub fn line_at_or_before(self, position: Samples, sr: SampleRate, rate: FrameRate, origin: i64) -> Samples {
self.sample_line(self.line_index(position, sr, rate, origin), sr, rate, origin)
}
/// The next distinct sample line; subframe increments may share a sample at low sample rates.
fn next_line(self, position: Samples, sr: SampleRate, rate: FrameRate, origin: i64) -> Samples {
self.sample_line(self.line_index(position, sr, rate, origin) + 1, sr, rate, origin).max(position.saturating_add(1))
}
/// Snap against frame-origin lines, choosing the earlier line when distances are equal.
pub fn snap(self, position: Samples, sr: SampleRate, rate: FrameRate, origin: i64) -> Samples {
let lo = self.line_at_or_before(position, sr, rate, origin);
let hi = self.next_line(lo, sr, rate, origin);
if i128::from(position) - i128::from(lo) <= i128::from(hi) - i128::from(position) { lo } else { hi }
}
/// Distinct Feet+Frames sample lines within `[start, end)`, capped at `max`.
pub fn lines(self, start: Samples, end: Samples, sr: SampleRate, rate: FrameRate, origin: i64, max: usize) -> Vec<Samples> {
let mut out = Vec::new();
let mut line = self.line_at_or_before(start, sr, rate, origin);
while line < end && out.len() < max {
if line >= start {
out.push(line);
}
let next = self.next_line(line, sr, rate, origin);
if next <= line {
break;
}
line = next;
}
out
}
}
/// A grid or nudge step.
#[derive(Debug, Clone, Copy, PartialEq, serde::Serialize, serde::Deserialize)]
pub enum GridValue {
/// Musical: note value, optionally dotted or triplet.
Note { value: NoteValue, dotted: bool, triplet: bool },
/// Absolute seconds (Min:Secs grids: 0.001 … 1 s).
Seconds(f64),
/// Frames at the session timecode rate.
/// Kept for sessions and commands written before named timecode increments were added.
Frames(i64),
/// A named Timecode increment, including fractional frames and one second.
Timecode(TimecodeValue),
/// A Feet+Frames increment (one foot is 16 frames).
FeetFrames(FeetFramesValue),
/// Raw samples.
Samples(i64),
}
impl Default for GridValue {
/// Starts on a straight sixteenth-note Bars|Beats grid.
fn default() -> Self {
GridValue::Note { value: NoteValue::Sixteenth, dotted: false, triplet: false }
}
}
impl GridValue {
/// Returns the user-facing label for this increment.
pub fn label(&self) -> String {
match self {
GridValue::Note { value, dotted, triplet } => {
let mut s = value.label().to_string();
if *dotted {
s.push_str(" dotted");
}
if *triplet {
s.push_str(" triplet");
}
s
}
GridValue::Seconds(s) => {
if *s >= 1.0 {
format!("{s} second{}", if *s == 1.0 { "" } else { "s" })
} else {
format!("{} ms", (s * 1000.0).round() as i64)
}
}
GridValue::Frames(f) => format!("{f} frame{}", if *f == 1 { "" } else { "s" }),
GridValue::Timecode(value) => value.label().to_string(),
GridValue::FeetFrames(value) => value.label().to_string(),
GridValue::Samples(n) => format!("{n} sample{}", if *n == 1 { "" } else { "s" }),
}
}
/// Returns the time scale to which this increment belongs.
pub fn scale(self) -> GridScale {
match self {
GridValue::Note { .. } => GridScale::BarsBeats,
GridValue::Seconds(_) => GridScale::MinSecs,
GridValue::Frames(_) | GridValue::Timecode(_) => GridScale::Timecode,
GridValue::FeetFrames(_) => GridScale::FeetFrames,
GridValue::Samples(_) => GridScale::Samples,
}
}
/// Returns the default increment for a newly selected time scale.
pub fn for_scale(scale: GridScale) -> GridValue {
match scale {
GridScale::BarsBeats => GridValue::default(),
GridScale::MinSecs => GridValue::Seconds(1.0),
GridScale::Timecode => GridValue::Timecode(TimecodeValue::Frame),
GridScale::FeetFrames => GridValue::FeetFrames(FeetFramesValue::Frame),
GridScale::Samples => GridValue::Samples(100),
GridScale::ClipsMarkers => GridValue::default(),
}
}
/// The next larger or smaller menu value of the same kind, or `self` at either end. Note values keep
/// their dot or triplet modifier.
pub fn stepped(self, larger: bool) -> GridValue {
/// Finds the next value in a numeric increment ladder without wrapping.
fn next<T: PartialOrd + Copy>(ladder: &[T], v: T, larger: bool) -> Option<T> {
if larger { ladder.iter().copied().find(|x| *x > v) } else { ladder.iter().rev().copied().find(|x| *x < v) }
}
match self {
GridValue::Note { value, dotted, triplet } => {
let i = NoteValue::ALL.iter().position(|v| *v == value).unwrap_or(0);
let j = if larger { i.checked_sub(1) } else { i.checked_add(1) };
j.and_then(|j| NoteValue::ALL.get(j)).map_or(self, |&value| GridValue::Note { value, dotted, triplet })
}
GridValue::Seconds(s) => next(&[0.001, 0.01, 0.1, 0.5, 1.0], s, larger).map_or(self, GridValue::Seconds),
GridValue::Frames(f) => next(&[1, 10, 100], f, larger).map_or(self, GridValue::Frames),
GridValue::Timecode(value) => step_enum(&TimecodeValue::ALL, value, larger).map_or(self, GridValue::Timecode),
GridValue::FeetFrames(value) => step_enum(&FeetFramesValue::ALL, value, larger).map_or(self, GridValue::FeetFrames),
GridValue::Samples(n) => next(&[1, 2, 10, 100, 1_000, 10_000], n, larger).map_or(self, GridValue::Samples),
}
}
fn note_ticks(value: NoteValue, dotted: bool, triplet: bool, map: &TempoMap, at_tick: i64) -> i64 {
let base = if value == NoteValue::Bar { map.meter_at_tick(at_tick).ticks_per_bar() } else { value.ticks() };
let mut t = base;
if dotted {
t = t * 3 / 2;
}
if triplet {
t = t * 2 / 3;
}
t.max(1)
}
/// The step in samples at position `at` (musical steps depend on tempo).
pub fn step_samples(&self, at: Samples, sr: SampleRate, map: &TempoMap, rate: FrameRate) -> Samples {
match *self {
GridValue::Note { value, dotted, triplet } => {
let t0 = map.samples_to_ticks(at, sr);
let dt = Self::note_ticks(value, dotted, triplet, map, t0);
(map.tick_to_samples(t0.saturating_add(dt), sr).saturating_sub(map.tick_to_samples(t0, sr))).max(1)
}
GridValue::Seconds(s) => sr.samples(s.clamp(0.000_01, 3600.0)).max(1),
GridValue::Frames(f) => to_samples(rate.samples_per_frame(sr) * f.clamp(1, 1_000_000) as f64).max(1),
GridValue::Timecode(value) => to_samples(rate.samples_per_frame(sr) * value.frames(rate)).max(1),
GridValue::FeetFrames(value) => to_samples(rate.samples_per_frame(sr) * value.frames()).max(1),
GridValue::Samples(n) => n.max(1),
}
}
/// Nearest grid line to `pos`.
pub fn snap(&self, pos: Samples, sr: SampleRate, map: &TempoMap, rate: FrameRate) -> Samples {
if let Self::FeetFrames(value) = *self {
return value.snap(pos, sr, rate, 0);
}
let lo = self.line_at_or_before(pos, sr, map, rate);
let hi = self.next_line(lo, sr, map, rate);
if (pos - lo) <= (hi - pos) { lo } else { hi }
}
/// The grid line at or before `pos`.
pub fn line_at_or_before(&self, pos: Samples, sr: SampleRate, map: &TempoMap, rate: FrameRate) -> Samples {
match *self {
Self::FeetFrames(value) => value.line_at_or_before(pos, sr, rate, 0),
GridValue::Note { value, dotted, triplet } => {
// Float ticks can land a hair either side of a line's own tick.
let mut t = map.samples_to_ticks_f(pos, sr).floor() as i64;
if map.tick_to_samples(t, sr) > pos {
t = t.saturating_sub(1);
} else if map.tick_to_samples(t.saturating_add(1), sr) <= pos {
t = t.saturating_add(1);
}
// Sub-bar values restart at each bar so odd meters stay aligned. A whole note is
// always four quarter notes and therefore keeps a continuous tick origin.
let bb = map.bar_beat_at_tick(t);
let bar_tick = map.bar_start_tick(bb.bar);
let origin = if value == NoteValue::Whole || (value == NoteValue::Bar && dotted) { 0 } else { bar_tick };
let step = Self::note_ticks(value, dotted, triplet, map, origin);
let k = (t - origin).div_euclid(step);
map.tick_to_samples(origin + k * step, sr)
}
_ => self.abs_line(self.abs_index_at_or_before(pos, sr, rate), sr, rate),
}
}
/// Position of absolute grid line `k` (Seconds, Frames, Samples), at an exact multiple of the step
/// rounded once, so rounding never accumulates from line to line.
fn abs_line(&self, k: i64, sr: SampleRate, rate: FrameRate) -> Samples {
if let Self::FeetFrames(value) = *self {
return value.sample_line(i128::from(k), sr, rate, 0);
}
if matches!(self, GridValue::Timecode(TimecodeValue::Second)) && rate.is_drop() {
return rate.samples_at_frame(Self::drop_second_frame(k, rate), sr);
}
if let Some(frames) = self.frame_step(rate) {
let whole = frames.round();
if frames == whole {
return rate.samples_at_frame(k.saturating_mul(whole as i64), sr);
}
let step = (rate.samples_per_frame(sr) * frames).max(1.0);
return to_samples(k as f64 * step);
}
match *self {
GridValue::Seconds(s) => to_samples(k as f64 * Self::seconds_step(s, sr)),
GridValue::Samples(n) => k.saturating_mul(n.max(1)),
GridValue::Note { .. } => 0,
GridValue::Frames(_) | GridValue::Timecode(_) | GridValue::FeetFrames(_) => 0,
}
}
/// Index of the absolute grid line at or before `pos`.
fn abs_index_at_or_before(&self, pos: Samples, sr: SampleRate, rate: FrameRate) -> i64 {
if matches!(self, GridValue::Timecode(TimecodeValue::Second)) && rate.is_drop() {
let tc = Timecode::from_frames(rate.frames_at(pos, sr), rate);
let magnitude = tc.hours.saturating_mul(3600).saturating_add(tc.minutes.saturating_mul(60)).saturating_add(tc.seconds);
let mut k = if tc.negative { magnitude.saturating_neg() } else { magnitude };
while self.abs_line(k, sr, rate) > pos {
let previous = k.saturating_sub(1);
if previous == k {
break;
}
k = previous;
}
while self.abs_line(k.saturating_add(1), sr, rate) <= pos {
let next = k.saturating_add(1);
if next == k {
break;
}
k = next;
}
return k;
}
let mut k = if let Some(frames) = self.frame_step(rate) {
let step = (rate.samples_per_frame(sr) * frames).max(1.0);
(pos as f64 / step).floor() as i64
} else {
match *self {
GridValue::Seconds(s) => (pos as f64 / Self::seconds_step(s, sr)).floor() as i64,
GridValue::Samples(n) => return pos.div_euclid(n.max(1)),
GridValue::Note { .. } => return 0,
GridValue::Frames(_) | GridValue::Timecode(_) | GridValue::FeetFrames(_) => return 0,
}
};
// Lines are rounded to whole samples, so the floored index can be one off either way.
if self.abs_line(k, sr, rate) > pos {
k = k.saturating_sub(1);
} else if self.abs_line(k.saturating_add(1), sr, rate) <= pos {
k = k.saturating_add(1);
}
k
}
/// The exact (fractional) Seconds step in samples, at least one sample.
fn seconds_step(s: f64, sr: SampleRate) -> f64 {
(s.clamp(0.000_01, 3600.0) * sr.as_f64()).max(1.0)
}
/// Maps a signed whole-second timecode label to its physical frame at a drop-frame rate.
fn drop_second_frame(index: i64, rate: FrameRate) -> i64 {
let seconds = index.unsigned_abs();
let total_minutes = seconds / 60;
let frames = if seconds.is_multiple_of(60) && !total_minutes.is_multiple_of(10) { if rate.nominal() >= 60 { 4 } else { 2 } } else { 0 };
Timecode {
negative: index < 0,
hours: i64::try_from(seconds / 3600).unwrap_or(i64::MAX),
minutes: i64::try_from((seconds / 60) % 60).unwrap_or(0),
seconds: i64::try_from(seconds % 60).unwrap_or(0),
frames,
}
.to_frames(rate)
}
/// A frame-based increment as a possibly fractional number of frames.
fn frame_step(&self, rate: FrameRate) -> Option<f64> {
match *self {
GridValue::Frames(frames) => Some(frames.clamp(1, 1_000_000) as f64),
GridValue::Timecode(value) => Some(value.frames(rate)),
GridValue::FeetFrames(value) => Some(value.frames()),
_ => None,
}
}
/// The first grid line strictly after `line`.
pub fn next_line(&self, line: Samples, sr: SampleRate, map: &TempoMap, rate: FrameRate) -> Samples {
if let Self::FeetFrames(value) = *self {
return value.next_line(line, sr, rate, 0);
}
if !matches!(self, GridValue::Note { .. }) {
let next = self.abs_line(self.abs_index_at_or_before(line, sr, rate).saturating_add(1), sr, rate);
return if next > line { next } else { line.saturating_add(1) };
}
let step = self.step_samples(line, sr, map, rate);
let candidate = self.line_at_or_before(line.saturating_add(step), sr, map, rate);
let next = if candidate > line { candidate } else { line.saturating_add(step) };
if matches!(self, GridValue::Note { value: NoteValue::Bar, .. }) {
return next;
}
let tick = map.samples_to_ticks(line, sr);
let bar = map.bar_beat_at_tick(tick).bar;
let next_bar = map.tick_to_samples(map.bar_start_tick(bar.saturating_add(1)), sr);
if next_bar > line { next.min(next_bar) } else { next }
}
/// All grid lines within `[start, end)`, capped at `max` lines.
pub fn lines(&self, start: Samples, end: Samples, sr: SampleRate, map: &TempoMap, rate: FrameRate, max: usize) -> Vec<Samples> {
if let Self::FeetFrames(value) = *self {
return value.lines(start, end, sr, rate, 0, max);
}
let mut out = Vec::new();
let mut p = self.line_at_or_before(start, sr, map, rate);
while p < end && out.len() < max {
if p >= start {
out.push(p);
}
let n = self.next_line(p, sr, map, rate);
if n <= p {
break;
}
p = n;
}
out
}
}
/// Moves one position through an ordered increment menu without wrapping.
fn step_enum<T: Copy + PartialEq>(values: &[T], value: T, larger: bool) -> Option<T> {
let index = values.iter().position(|candidate| *candidate == value)?;
let next = if larger { index.checked_sub(1) } else { index.checked_add(1) }?;
values.get(next).copied()
}
#[cfg(test)]
mod tests {
use super::*;
/// Frame origins are subtracted before rounding whole frames, quarter frames and subframes.
#[test]
fn feet_frames_origins_round_only_after_applying_the_frame_offset() {
let (sr, rate) = (SampleRate::HZ_48000, FrameRate::Fps29_97);
for at in [49_650, 100_901, 177_778] {
assert_eq!(FeetFramesValue::Foot.line_at_or_before(at, sr, rate, 1), at);
assert_eq!(FeetFramesValue::Foot.snap(at - 1, sr, rate, 1), at);
}
assert_eq!(FeetFramesValue::QuarterFrame.snap(50_049, sr, rate, 1), 50_050);
assert_eq!(FeetFramesValue::SubFrame.lines(49_665, 49_667, sr, rate, 1, 4), [49_666]);
}
/// Dense subframes retain distinct sample lines and remain bounded with extreme signed origins.
#[test]
fn feet_frames_subframes_handle_dense_steps_and_extreme_origins() {
let sr = SampleRate::new(8_000).unwrap();
let rate = FrameRate::Fps120;
for origin in [i64::MIN, -1, 0, 1, i64::MAX] {
assert_eq!(FeetFramesValue::SubFrame.lines(-3, 4, sr, rate, origin, 20), [-3, -2, -1, 0, 1, 2, 3]);
for at in [i64::MIN, -1, 0, 1, i64::MAX] {
assert_eq!(FeetFramesValue::SubFrame.line_at_or_before(at, sr, rate, origin), at);
assert_eq!(FeetFramesValue::SubFrame.snap(at, sr, rate, origin), at);
}
}
}
#[test]
fn grid_lines_are_their_own_line_at_odd_tempos() {
let (g, sr, rate) = (GridValue::default(), SampleRate::HZ_48000, FrameRate::Fps30);
for bpm in [90.0, 95.0, 110.0, 130.0, 140.0] {
let map = TempoMap::new(bpm, 4, 4).unwrap();
let mut line = 0;
for _ in 0..64 {
assert_eq!(g.line_at_or_before(line, sr, &map, rate), line, "{bpm} bpm");
line = g.next_line(line, sr, &map, rate);
}
}
}
/// Musical boundaries stay exact across a tempo change while absolute scales remain
/// independent of the tempo map.
#[test]
fn grids_stay_aligned_across_a_tempo_change() {
let (sr, rate) = (SampleRate::HZ_48000, FrameRate::Fps29_97Drop);
let mut map = TempoMap::default();
map.set_tempo(TICKS_PER_QUARTER * 4, 90.0).unwrap();
let musical = GridValue::Note { value: NoteValue::Quarter, dotted: false, triplet: false };
for tick in [0, TICKS_PER_QUARTER * 3, TICKS_PER_QUARTER * 4, TICKS_PER_QUARTER * 5] {
let line = map.tick_to_samples(tick, sr);
assert_eq!(musical.line_at_or_before(line, sr, &map, rate), line);
}
for absolute in [
GridValue::Seconds(0.5),
GridValue::Timecode(TimecodeValue::Second),
GridValue::FeetFrames(FeetFramesValue::Foot),
GridValue::Samples(1_000),
] {
let line = absolute.next_line(absolute.line_at_or_before(100_000, sr, &map, rate), sr, &map, rate);
assert_eq!(absolute.line_at_or_before(line, sr, &map, rate), line, "{absolute:?}");
}
}
#[test]
fn sixteenth_grid_at_120() {
let g = GridValue::default();
let (sr, map, rate) = (SampleRate::HZ_48000, TempoMap::default(), FrameRate::Fps30);
assert_eq!(g.step_samples(0, sr, &map, rate), 6000);
assert_eq!(g.snap(2900, sr, &map, rate), 0);
assert_eq!(g.snap(3100, sr, &map, rate), 6000);
assert_eq!(g.lines(0, 24_000, sr, &map, rate, 100), vec![0, 6000, 12000, 18000]);
}
/// Absolute increments report their expected sample lengths at a fixed rate.
#[test]
fn absolute_grids() {
let (sr, map, rate) = (SampleRate::HZ_48000, TempoMap::default(), FrameRate::Fps25);
assert_eq!(GridValue::Seconds(1.0).snap(70_000, sr, &map, rate), 48_000);
assert_eq!(GridValue::Frames(1).step_samples(0, sr, &map, rate), 1920);
assert_eq!(GridValue::Timecode(TimecodeValue::Second).step_samples(0, sr, &map, rate), 48_000);
assert_eq!(GridValue::Timecode(TimecodeValue::FiveFrames).step_samples(0, sr, &map, rate), 9_600);
assert_eq!(GridValue::Timecode(TimecodeValue::HalfFrame).step_samples(0, sr, &map, rate), 960);
assert_eq!(GridValue::Timecode(TimecodeValue::QuarterFrame).step_samples(0, sr, &map, rate), 480);
assert_eq!(GridValue::Timecode(TimecodeValue::SubFrame).step_samples(0, sr, &map, rate), 19);
assert_eq!(GridValue::FeetFrames(FeetFramesValue::Foot).step_samples(0, sr, &map, rate), 30_720);
assert_eq!(GridValue::Samples(0).step_samples(0, sr, &map, rate), 1);
}
/// Bar length follows the meter while a whole note always spans four quarter notes.
#[test]
fn a_bar_and_a_whole_note_differ_in_three_four() {
let (sr, map, rate) = (SampleRate::HZ_48000, TempoMap::new(120.0, 3, 4).unwrap(), FrameRate::Fps25);
let note = |value| GridValue::Note { value, dotted: false, triplet: false };
assert_eq!(note(NoteValue::Bar).step_samples(0, sr, &map, rate), 72_000);
assert_eq!(note(NoteValue::Whole).step_samples(0, sr, &map, rate), 96_000);
assert_eq!(note(NoteValue::Bar).line_at_or_before(96_000, sr, &map, rate), 72_000);
assert_eq!(note(NoteValue::Whole).line_at_or_before(96_000, sr, &map, rate), 96_000);
assert_eq!(note(NoteValue::Half).lines(0, 150_000, sr, &map, rate, 10), [0, 48_000, 72_000, 120_000, 144_000]);
}
#[test]
fn dotted_bar_grid_keeps_its_spacing_across_bars() {
let (sr, map, rate) = (SampleRate::HZ_48000, TempoMap::default(), FrameRate::Fps25);
let grid = GridValue::Note { value: NoteValue::Bar, dotted: true, triplet: false };
assert_eq!(grid.line_at_or_before(143_999, sr, &map, rate), 0);
assert_eq!(grid.line_at_or_before(144_000, sr, &map, rate), 144_000);
assert_eq!(grid.next_line(0, sr, &map, rate), 144_000);
}
#[test]
fn dotted_note_does_not_skip_a_meter_change_boundary() {
let sr = SampleRate::HZ_48000;
let mut map = TempoMap::default();
map.set_meter(TICKS_PER_QUARTER * 4, 2, 4).unwrap();
let rate = FrameRate::Fps25;
let grid = GridValue::Note { value: NoteValue::Half, dotted: true, triplet: false };
assert_eq!(grid.next_line(72_000, sr, &map, rate), 96_000);
assert!(grid.lines(72_000, 120_000, sr, &map, rate, 10).contains(&96_000));
assert_eq!(grid.snap(90_000, sr, &map, rate), 96_000);
}
#[test]
fn seconds_grid_lines_land_on_whole_seconds() {
let (sr, map, rate) = (SampleRate::HZ_44100, TempoMap::default(), FrameRate::Fps30);
for g in [GridValue::Seconds(0.001), GridValue::Seconds(0.01)] {
for n in 0..20 {
let s = n * 44_100;
assert_eq!(g.snap(s, sr, &map, rate), s, "{g:?} at {n} s");
assert_eq!(g.line_at_or_before(s, sr, &map, rate), s, "{g:?} at {n} s");
}
}
}
/// Legacy frame grids stay aligned to exact frame starts at fractional rates.
#[test]
fn frame_grid_lines_land_on_frame_starts() {
let sr = SampleRate::HZ_48000;
let map = TempoMap::default();
for rate in [FrameRate::Fps29_97, FrameRate::Fps23_976] {
for f in [1, 10] {
let g = GridValue::Frames(f);
for k in (0..5000).step_by(37) {
let s = rate.samples_at_frame(k, sr);
let line = rate.samples_at_frame(k.div_euclid(f) * f, sr);
assert_eq!(g.line_at_or_before(s, sr, &map, rate), line, "{rate:?} {f} frames at frame {k}");
if k % f == 0 {
assert_eq!(g.snap(s, sr, &map, rate), s, "{rate:?} {f} frames at frame {k}");
}
}
let start = rate.samples_at_frame(4000, sr);
let end = rate.samples_at_frame(4000 + 20 * f, sr);
let want: Vec<_> = (0..20).map(|i| rate.samples_at_frame(4000 + i * f, sr)).collect();
assert_eq!(g.lines(start, end, sr, &map, rate, 100), want, "{rate:?} {f} frames");
}
}
}
/// Named whole- and sub-frame grids retain the exact-multiple calculation used by legacy frame grids.
#[test]
fn named_frame_grids_land_on_exact_subdivisions() {
let sr = SampleRate::HZ_48000;
let map = TempoMap::default();
let grids = TimecodeValue::ALL.into_iter().map(GridValue::Timecode).chain(FeetFramesValue::ALL.into_iter().map(GridValue::FeetFrames));
for rate in [FrameRate::Fps29_97, FrameRate::Fps23_976] {
for grid in grids.clone() {
for index in 0..200 {
let line = grid.abs_line(index, sr, rate);
assert_eq!(grid.line_at_or_before(line, sr, &map, rate), line, "{grid:?} {rate:?} line {index}");
assert_eq!(grid.next_line(line, sr, &map, rate), grid.abs_line(index + 1, sr, rate), "{grid:?} {rate:?} line {index}");
}
}
}
}
/// Drop-frame second grids follow labelled second boundaries in both timeline directions.
#[test]
fn drop_frame_second_grids_follow_timecode_labels() {
let sr = SampleRate::HZ_48000;
let map = TempoMap::default();
let grid = GridValue::Timecode(TimecodeValue::Second);
for rate in [FrameRate::Fps29_97Drop, FrameRate::Fps59_94Drop] {
for seconds in [0, 59, 60, 61, 600, 601, 3600] {
let frame = GridValue::drop_second_frame(seconds, rate);
let line = rate.samples_at_frame(frame, sr);
assert_eq!(grid.line_at_or_before(line, sr, &map, rate), line, "{rate:?} second {seconds}");
let negative_line = rate.samples_at_frame(-frame, sr);
assert_eq!(grid.line_at_or_before(negative_line, sr, &map, rate), negative_line, "{rate:?} second -{seconds}");
for offset in -2..=rate.nominal() + 2 {
let position = rate.samples_at_frame(frame.saturating_add(offset), sr);
let before = grid.line_at_or_before(position, sr, &map, rate);
assert!(before <= position, "{rate:?} second {seconds}, frame offset {offset}");
assert!(grid.next_line(before, sr, &map, rate) > position, "{rate:?} second {seconds}, frame offset {offset}");
}
}
let labelled = Timecode { negative: false, hours: 0, minutes: 1, seconds: 1, frames: 0 }.to_samples(rate, sr);
assert_eq!(grid.snap(labelled, sr, &map, rate), labelled);
}
for (rate, minute_frame) in [(FrameRate::Fps29_97Drop, 1800), (FrameRate::Fps59_94Drop, 3600)] {
let boundary = rate.samples_at_frame(minute_frame, sr);
assert_eq!(grid.line_at_or_before(boundary, sr, &map, rate), boundary, "{rate:?} positive minute boundary");
assert_eq!(grid.snap(boundary, sr, &map, rate), boundary, "{rate:?} positive minute snap");
let negative_boundary = rate.samples_at_frame(-minute_frame, sr);
assert_eq!(grid.line_at_or_before(negative_boundary, sr, &map, rate), negative_boundary, "{rate:?} negative minute boundary");
assert_eq!(grid.snap(negative_boundary, sr, &map, rate), negative_boundary, "{rate:?} negative minute snap");
}
}
#[test]
fn lines_are_capped() {
let (sr, map, rate) = (SampleRate::HZ_48000, TempoMap::default(), FrameRate::Fps25);
assert_eq!(GridValue::Samples(1).lines(0, 1_000_000, sr, &map, rate, 50).len(), 50);
}
#[test]
fn triplets_and_dots() {
let (sr, map, rate) = (SampleRate::HZ_48000, TempoMap::default(), FrameRate::Fps25);
let t = GridValue::Note { value: NoteValue::Quarter, dotted: false, triplet: true };
assert_eq!(t.step_samples(0, sr, &map, rate), 16_000);
let d = GridValue::Note { value: NoteValue::Quarter, dotted: true, triplet: false };
assert_eq!(d.step_samples(0, sr, &map, rate), 36_000);
}
/// Stepping follows each menu ladder and stops at its endpoints.
#[test]
fn values_step_to_the_next_larger_or_smaller() {
let note = |value, triplet| GridValue::Note { value, dotted: false, triplet };
assert_eq!(note(NoteValue::Sixteenth, false).stepped(true), note(NoteValue::Eighth, false));
assert_eq!(note(NoteValue::Eighth, true).stepped(false), note(NoteValue::Sixteenth, true));
assert_eq!(note(NoteValue::Bar, false).stepped(true), note(NoteValue::Bar, false));
assert_eq!(note(NoteValue::SixtyFourth, false).stepped(false), note(NoteValue::SixtyFourth, false));
assert_eq!(GridValue::Seconds(0.01).stepped(true), GridValue::Seconds(0.1));
assert_eq!(GridValue::Seconds(0.5).stepped(false), GridValue::Seconds(0.1));
assert_eq!(GridValue::Seconds(1.0).stepped(true), GridValue::Seconds(1.0));
assert_eq!(GridValue::Frames(1).stepped(true), GridValue::Frames(10));
assert_eq!(GridValue::Timecode(TimecodeValue::Frame).stepped(false), GridValue::Timecode(TimecodeValue::HalfFrame));
assert_eq!(GridValue::FeetFrames(FeetFramesValue::Frame).stepped(true), GridValue::FeetFrames(FeetFramesValue::Foot));
assert_eq!(GridValue::Samples(100).stepped(false), GridValue::Samples(10));
assert_eq!(GridValue::Samples(1).stepped(true), GridValue::Samples(2));
assert_eq!(GridValue::Samples(1).stepped(false), GridValue::Samples(1));
}
#[test]
fn snap_at_extreme_position_does_not_overflow() {
let sr = SampleRate::new(8000).unwrap();
let map = TempoMap::new(1000.0, 4, 4).unwrap();
let _ = GridValue::default().snap(i64::MAX, sr, &map, FrameRate::Fps30);
}
}
+181
View File
@@ -0,0 +1,181 @@
//! SoundCraft timebases.
//!
//! Every position in a session is an absolute sample index (`i64`) at the session sample rate.
//! This crate converts those positions to and from the five Pro Tools-style timebases
//! (Bars|Beats, Min:Secs, Timecode, Feet+Frames, Samples) through a [`TempoMap`], and computes
//! grid lines and snapping for the Grid / Nudge values.
#![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)]
mod format;
mod grid;
mod tempo;
mod timecode;
pub use format::{TimeFormat, format_length, format_position, parse_position};
pub use grid::{FeetFramesValue, GridScale, GridValue, NoteValue, TimecodeValue};
pub use tempo::{BarBeat, MeterEvent, TICKS_PER_QUARTER, TempoEvent, TempoMap};
pub use timecode::{FrameRate, Timecode};
/// A position or length in samples.
pub type Samples = i64;
/// Largest position accepted from parameters and files: 2^40 samples (about 265 days at 48 kHz).
/// Clamping there keeps every later addition and subtraction on positions far away from overflow.
pub const MAX_POSITION: Samples = 1 << 40;
/// Errors from parsing or converting time values.
#[derive(Debug, Clone, PartialEq, thiserror::Error)]
pub enum TimeError {
#[error("cannot parse `{0}` as a {1} value")]
Parse(String, &'static str),
#[error("invalid sample rate {0}")]
SampleRate(u32),
#[error("invalid tempo {0}")]
Tempo(f64),
#[error("invalid meter {0}/{1}")]
Meter(u32, u32),
}
/// Sample rates supported by sessions (Pro Tools offers 44.1 k – 192 k; we accept 8 k – 768 k).
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, serde::Serialize, serde::Deserialize)]
#[serde(try_from = "u32")]
pub struct SampleRate(u32);
impl TryFrom<u32> for SampleRate {
type Error = TimeError;
fn try_from(hz: u32) -> Result<Self, Self::Error> {
Self::new(hz)
}
}
impl SampleRate {
pub const HZ_44100: SampleRate = SampleRate(44_100);
pub const HZ_48000: SampleRate = SampleRate(48_000);
pub const COMMON: [u32; 8] = [44_100, 48_000, 88_200, 96_000, 176_400, 192_000, 352_800, 384_000];
pub fn new(hz: u32) -> Result<Self, TimeError> {
if (8_000..=768_000).contains(&hz) { Ok(SampleRate(hz)) } else { Err(TimeError::SampleRate(hz)) }
}
pub fn hz(self) -> u32 {
self.0
}
pub fn as_f64(self) -> f64 {
f64::from(self.0)
}
/// Samples → seconds.
pub fn seconds(self, s: Samples) -> f64 {
s as f64 / self.as_f64()
}
/// Seconds → samples, rounded to nearest and saturated.
pub fn samples(self, seconds: f64) -> Samples {
to_samples(seconds * self.as_f64())
}
}
impl Default for SampleRate {
fn default() -> Self {
SampleRate::HZ_48000
}
}
/// Round a float sample count to `i64`, mapping NaN to 0 and saturating infinities.
pub fn to_samples(v: f64) -> Samples {
if v.is_nan() {
0
} else if v >= i64::MAX as f64 {
i64::MAX
} else if v <= i64::MIN as f64 {
i64::MIN
} else {
v.round() as i64
}
}
/// A half-open sample range `[start, end)`.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Default, serde::Serialize, serde::Deserialize)]
pub struct Range {
pub start: Samples,
pub end: Samples,
}
impl Range {
pub fn new(a: Samples, b: Samples) -> Self {
if a <= b { Range { start: a, end: b } } else { Range { start: b, end: a } }
}
pub fn point(at: Samples) -> Self {
Range { start: at, end: at }
}
pub fn len(&self) -> Samples {
self.end.saturating_sub(self.start)
}
pub fn is_empty(&self) -> bool {
self.end <= self.start
}
pub fn contains(&self, s: Samples) -> bool {
s >= self.start && s < self.end
}
pub fn overlaps(&self, o: &Range) -> bool {
!self.is_empty() && !o.is_empty() && self.start < o.end && o.start < self.end
}
pub fn intersect(&self, o: &Range) -> Option<Range> {
let s = self.start.max(o.start);
let e = self.end.min(o.end);
(s < e).then_some(Range { start: s, end: e })
}
pub fn shifted(&self, by: Samples) -> Range {
Range { start: self.start.saturating_add(by), end: self.end.saturating_add(by) }
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn sample_rate_serde_validates_nonzero_bounds() {
assert!(serde_json::from_str::<SampleRate>("0").is_err());
assert!(serde_json::from_str::<SampleRate>("7999").is_err());
let rate = SampleRate::new(48_000).unwrap();
assert_eq!(serde_json::to_string(&rate).unwrap(), "48000");
assert_eq!(serde_json::from_str::<SampleRate>("48000").unwrap(), rate);
}
#[test]
fn empty_ranges_never_overlap() {
let point = Range::point(5);
let span = Range::new(0, 10);
assert!(!point.overlaps(&span));
assert!(!span.overlaps(&point));
assert!(Range::new(0, 5).overlaps(&span));
}
#[test]
fn sample_rate_bounds() {
assert!(SampleRate::new(48_000).is_ok());
assert!(SampleRate::new(0).is_err());
assert!(SampleRate::new(10_000_000).is_err());
}
#[test]
fn seconds_round_trip() {
let sr = SampleRate::HZ_44100;
assert_eq!(sr.samples(1.0), 44_100);
assert!((sr.seconds(22_050) - 0.5).abs() < 1e-12);
}
#[test]
fn to_samples_is_total() {
assert_eq!(to_samples(f64::NAN), 0);
assert_eq!(to_samples(f64::INFINITY), i64::MAX);
assert_eq!(to_samples(f64::NEG_INFINITY), i64::MIN);
}
#[test]
fn range_ops() {
let a = Range::new(10, 0);
assert_eq!(a, Range { start: 0, end: 10 });
assert_eq!(a.intersect(&Range::new(5, 20)), Some(Range::new(5, 10)));
assert_eq!(a.intersect(&Range::new(10, 20)), None);
assert!(a.contains(9) && !a.contains(10));
}
}
+622
View File
@@ -0,0 +1,622 @@
//! Tempo and meter maps: the conversion between musical time (ticks) and samples.
use super::{MAX_POSITION, SampleRate, Samples, TimeError, to_samples};
/// Ticks per quarter note (Pro Tools' resolution).
pub const TICKS_PER_QUARTER: i64 = 960;
/// A tempo change at a tick position. `bpm` is quarter notes per minute.
#[derive(Debug, Clone, Copy, PartialEq, serde::Serialize, serde::Deserialize)]
pub struct TempoEvent {
pub tick: i64,
pub bpm: f64,
}
/// A meter (time signature) change at a tick position, which must fall on a bar line.
#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
pub struct MeterEvent {
pub tick: i64,
pub numerator: u32,
pub denominator: u32,
}
impl MeterEvent {
pub fn ticks_per_beat(&self) -> i64 {
let d = i64::from(self.denominator.max(1));
(TICKS_PER_QUARTER * 4 / d).max(1)
}
pub fn ticks_per_bar(&self) -> i64 {
self.ticks_per_beat().saturating_mul(i64::from(self.numerator.max(1)))
}
}
/// A musical position: 1-based bar and beat plus ticks within the beat.
#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
pub struct BarBeat {
pub bar: i64,
pub beat: i64,
pub tick: i64,
}
/// Tempo and meter changes for a session. Always has at least one of each, at tick 0.
#[derive(Debug, Clone, PartialEq, serde::Serialize, serde::Deserialize)]
#[serde(from = "TempoMapData")]
pub struct TempoMap {
tempos: Vec<TempoEvent>,
meters: Vec<MeterEvent>,
/// For each tempo event, the elapsed time at its tick in quarter-note minutes (ticks / bpm,
/// summed over the earlier segments). Lets tick/sample conversions binary-search the segment
/// instead of walking every earlier tempo change, so converting many positions against a large
/// tempo map stays O(log n) each. Rebuilt whenever `tempos` changes.
#[serde(skip)]
elapsed: Vec<f64>,
}
/// The serialized form of [`TempoMap`], without the derived lookup table.
#[derive(serde::Deserialize)]
struct TempoMapData {
tempos: Vec<TempoEvent>,
meters: Vec<MeterEvent>,
}
impl From<TempoMapData> for TempoMap {
fn from(d: TempoMapData) -> Self {
TempoMap::from_parts(d.tempos, d.meters)
}
}
impl Default for TempoMap {
fn default() -> Self {
TempoMap::from_parts(vec![TempoEvent { tick: 0, bpm: 120.0 }], vec![MeterEvent { tick: 0, numerator: 4, denominator: 4 }])
}
}
fn valid_bpm(bpm: f64) -> Result<f64, TimeError> {
if bpm.is_finite() && (5.0..=1000.0).contains(&bpm) { Ok(bpm) } else { Err(TimeError::Tempo(bpm)) }
}
fn valid_meter(numerator: u32, denominator: u32) -> bool {
(1..=64).contains(&numerator) && [1, 2, 4, 8, 16, 32, 64].contains(&denominator)
}
impl TempoMap {
fn from_parts(tempos: Vec<TempoEvent>, meters: Vec<MeterEvent>) -> Self {
let mut m = TempoMap { tempos, meters, elapsed: Vec::new() };
m.reindex();
m
}
/// Rebuild `elapsed` from `tempos`. The first segment runs from tick 0 at the first tempo.
fn reindex(&mut self) {
let mut elapsed = Vec::with_capacity(self.tempos.len());
let mut acc = 0.0;
let mut seg_tick = 0i64;
let mut bpm = self.tempos.first().map_or(120.0, |e| e.bpm);
for (i, e) in self.tempos.iter().enumerate() {
if i > 0 {
// Saturating: a file's ticks are indexed before `sanitize` orders them.
acc += e.tick.saturating_sub(seg_tick) as f64 / bpm;
seg_tick = e.tick;
bpm = e.bpm;
}
elapsed.push(acc);
}
self.elapsed = elapsed;
}
/// Samples per quarter-note minute (the unit of `elapsed`) at `sr`.
fn samples_per_elapsed(sr: SampleRate) -> f64 {
sr.as_f64() * 60.0 / TICKS_PER_QUARTER as f64
}
/// The tempo segment `i` (an index into `tempos`, 0 when it is empty): its start tick (0 for
/// the first), bpm and elapsed time at its start.
fn segment(&self, i: usize) -> (i64, f64, f64) {
let bpm = self.tempos.get(i).or(self.tempos.first()).map_or(120.0, |e| e.bpm);
let tick = if i == 0 { 0 } else { self.tempos.get(i).map_or(0, |e| e.tick) };
(tick, bpm, self.elapsed.get(i).copied().unwrap_or(0.0))
}
pub fn new(bpm: f64, numerator: u32, denominator: u32) -> Result<Self, TimeError> {
let mut m = TempoMap::default();
m.set_tempo(0, bpm)?;
m.set_meter(0, numerator, denominator)?;
Ok(m)
}
pub fn tempos(&self) -> &[TempoEvent] {
&self.tempos
}
pub fn meters(&self) -> &[MeterEvent] {
&self.meters
}
/// Repair a map read from a file: only valid tempos and meters within `0..=MAX_POSITION` ticks, in
/// tick order, with one of each at tick 0.
pub fn sanitize(&mut self) {
self.tempos.retain(|e| (0..=MAX_POSITION).contains(&e.tick) && valid_bpm(e.bpm).is_ok());
self.tempos.sort_by_key(|e| e.tick);
self.tempos.dedup_by_key(|e| e.tick);
if self.tempos.first().is_none_or(|e| e.tick > 0) {
let bpm = self.tempos.first().map_or(120.0, |e| e.bpm);
self.tempos.insert(0, TempoEvent { tick: 0, bpm });
}
self.meters.retain(|m| (0..=MAX_POSITION).contains(&m.tick) && valid_meter(m.numerator, m.denominator));
self.meters.sort_by_key(|m| m.tick);
self.meters.dedup_by_key(|m| m.tick);
if self.meters.first().is_none_or(|m| m.tick > 0) {
let (numerator, denominator) = self.meters.first().map_or((4, 4), |m| (m.numerator, m.denominator));
self.meters.insert(0, MeterEvent { tick: 0, numerator, denominator });
}
self.reindex();
}
/// Insert or replace the tempo at `tick`.
pub fn set_tempo(&mut self, tick: i64, bpm: f64) -> Result<(), TimeError> {
let bpm = valid_bpm(bpm)?;
let tick = tick.max(0);
match self.tempos.binary_search_by(|e| e.tick.cmp(&tick)) {
Ok(i) => {
if let Some(e) = self.tempos.get_mut(i) {
e.bpm = bpm;
}
}
Err(i) => self.tempos.insert(i, TempoEvent { tick, bpm }),
}
self.reindex();
Ok(())
}
/// Remove a tempo event (the one at tick 0 is kept).
pub fn remove_tempo(&mut self, tick: i64) -> bool {
if tick == 0 {
return false;
}
let before = self.tempos.len();
self.tempos.retain(|e| e.tick != tick);
self.reindex();
before != self.tempos.len()
}
/// Insert or replace the meter at `tick` (snapped to the bar line at or before it).
/// Later meter changes keep their bar numbers, so they stay on bar lines.
pub fn set_meter(&mut self, tick: i64, numerator: u32, denominator: u32) -> Result<(), TimeError> {
if !valid_meter(numerator, denominator) {
return Err(TimeError::Meter(numerator, denominator));
}
let tick = if tick <= 0 { 0 } else { self.bar_start_tick(self.bar_beat_at_tick(tick).bar) };
let later = self.take_meters_after(tick);
self.put_meter(MeterEvent { tick, numerator, denominator });
self.restore_meters_at_bars(later);
Ok(())
}
/// Remove a meter event (the one at tick 0 is kept). Later meter changes keep their bar numbers.
pub fn remove_meter(&mut self, tick: i64) -> bool {
if tick == 0 || !self.meters.iter().any(|e| e.tick == tick) {
return false;
}
let later = self.take_meters_after(tick);
self.meters.retain(|e| e.tick != tick);
self.restore_meters_at_bars(later);
true
}
fn put_meter(&mut self, ev: MeterEvent) {
match self.meters.binary_search_by(|e| e.tick.cmp(&ev.tick)) {
Ok(i) => {
if let Some(e) = self.meters.get_mut(i) {
*e = ev;
}
}
Err(i) => self.meters.insert(i, ev),
}
}
/// Remove the meters after `tick`, returning each with the bar it starts: the bar
/// [`Self::bar_beat_at_tick`] gives its tick, counted in one pass over the map rather than one
/// walk per meter, so a session with many meter changes edits in linear time.
fn take_meters_after(&mut self, tick: i64) -> Vec<(i64, MeterEvent)> {
let mut later = Vec::new();
let (mut bar, mut seg_tick) = (1i64, 0i64);
for (i, m) in self.meters.iter().enumerate() {
let tpb = m.ticks_per_bar();
if m.tick > tick {
later.push((bar.saturating_add(m.tick.saturating_sub(seg_tick) / tpb), *m));
}
if let Some(n) = self.meters.get(i + 1) {
bar = bar.saturating_add(ceil_div(n.tick.saturating_sub(seg_tick), tpb));
seg_tick = n.tick;
}
}
self.meters.retain(|e| e.tick <= tick);
later
}
/// The bar and tick of the last meter's segment, where [`Self::bar_start_tick`]'s walk ends.
fn last_segment(&self) -> (i64, i64) {
let (mut cur_bar, mut cur_tick) = (1i64, 0i64);
for (i, m) in self.meters.iter().enumerate() {
if let Some(n) = self.meters.get(i + 1) {
cur_bar = cur_bar.saturating_add(ceil_div(n.tick.saturating_sub(cur_tick), m.ticks_per_bar()));
cur_tick = n.tick;
}
}
(cur_bar, cur_tick)
}
/// Put meters back at the start of their bars under the current earlier meters, in bar order.
/// Each lands at or past the last meter, so the walk to its bar resumes from there; a bar
/// before it (meters off their bar lines) takes the full walk.
fn restore_meters_at_bars(&mut self, later: Vec<(i64, MeterEvent)>) {
let (mut cur_bar, mut cur_tick) = self.last_segment();
for (bar, ev) in later {
let tick = if bar >= cur_bar {
let tpb = self.meters.last().map_or(MeterEvent { tick: 0, numerator: 4, denominator: 4 }, |m| *m).ticks_per_bar();
cur_tick.saturating_add(bar.saturating_sub(cur_bar).saturating_mul(tpb))
} else {
self.bar_start_tick(bar)
};
self.put_meter(MeterEvent { tick, ..ev });
(cur_bar, cur_tick) = if bar >= cur_bar { (bar, tick) } else { self.last_segment() };
}
}
/// Tempo in effect at `tick`.
pub fn tempo_at_tick(&self, tick: i64) -> f64 {
let mut bpm = self.tempos.first().map_or(120.0, |e| e.bpm);
for e in &self.tempos {
if e.tick <= tick {
bpm = e.bpm;
} else {
break;
}
}
bpm
}
/// Meter in effect at `tick`.
pub fn meter_at_tick(&self, tick: i64) -> MeterEvent {
let mut m = self.meters.first().copied().unwrap_or(MeterEvent { tick: 0, numerator: 4, denominator: 4 });
for e in &self.meters {
if e.tick <= tick {
m = *e;
} else {
break;
}
}
m
}
fn samples_per_tick(bpm: f64, sr: SampleRate) -> f64 {
sr.as_f64() * 60.0 / (bpm * TICKS_PER_QUARTER as f64)
}
/// Ticks → samples (exact for constant-tempo segments).
pub fn tick_to_samples(&self, tick: i64, sr: SampleRate) -> Samples {
to_samples(self.tick_to_samples_f(tick, sr))
}
fn tick_to_samples_f(&self, tick: i64, sr: SampleRate) -> f64 {
// Tempo events after the first that start before `tick` (the segments it has passed).
let passed = self.tempos.get(1..).map_or(0, |rest| rest.partition_point(|e| e.tick < tick));
let (seg_tick, bpm, elapsed) = self.segment(passed);
elapsed * Self::samples_per_elapsed(sr) + (tick - seg_tick) as f64 * Self::samples_per_tick(bpm, sr)
}
/// Samples → ticks (fractional).
pub fn samples_to_ticks_f(&self, s: Samples, sr: SampleRate) -> f64 {
let s = s as f64;
let scale = Self::samples_per_elapsed(sr);
// Tempo events after the first that start at or before `s`.
let passed = self.elapsed.get(1..).map_or(0, |rest| rest.partition_point(|e| e * scale <= s));
let (seg_tick, bpm, elapsed) = self.segment(passed);
seg_tick as f64 + (s - elapsed * scale) / Self::samples_per_tick(bpm, sr)
}
pub fn samples_to_ticks(&self, s: Samples, sr: SampleRate) -> i64 {
to_samples(self.samples_to_ticks_f(s, sr))
}
/// Tick of the first beat of `bar` (1-based; bars before 1 extend the first meter backwards).
pub fn bar_start_tick(&self, bar: i64) -> i64 {
let mut cur_bar = 1i64;
let mut cur_tick = 0i64;
let first = self.meter_at_tick(0);
if bar < 1 {
return bar.saturating_sub(1).saturating_mul(first.ticks_per_bar());
}
for (i, m) in self.meters.iter().enumerate() {
let next_tick = self.meters.get(i + 1).map(|n| n.tick);
let tpb = m.ticks_per_bar();
let bars_in_seg = next_tick.map(|nt| ceil_div(nt.saturating_sub(cur_tick), tpb));
match bars_in_seg {
Some(n) if bar >= cur_bar.saturating_add(n) => {
cur_bar = cur_bar.saturating_add(n);
cur_tick = next_tick.unwrap_or(cur_tick);
}
_ => return cur_tick.saturating_add((bar - cur_bar).saturating_mul(tpb)),
}
}
cur_tick
}
/// Bar|beat|tick at `tick`.
pub fn bar_beat_at_tick(&self, tick: i64) -> BarBeat {
let first = self.meter_at_tick(0);
if tick < 0 {
let tpb = first.ticks_per_bar();
let abs = tick.unsigned_abs();
let tpb_u = tpb.unsigned_abs();
let bars_back_u = abs / tpb_u + u64::from(!abs.is_multiple_of(tpb_u));
let bars_back = i64::try_from(bars_back_u).unwrap_or(i64::MAX);
// Offset of `tick` inside its bar, without negating `i64::MIN`.
let within = ((tpb_u - abs % tpb_u) % tpb_u) as i64;
let beat_t = first.ticks_per_beat();
return BarBeat { bar: 1 - bars_back, beat: within / beat_t + 1, tick: within % beat_t };
}
let mut bar = 1i64;
let mut seg_tick = 0i64;
for (i, m) in self.meters.iter().enumerate() {
let tpb = m.ticks_per_bar();
if let Some(n) = self.meters.get(i + 1)
&& tick >= n.tick
{
bar = bar.saturating_add(ceil_div(n.tick.saturating_sub(seg_tick), tpb));
seg_tick = n.tick;
continue;
}
let rel = tick - seg_tick;
let bars = rel / tpb;
let within = rel % tpb;
let beat_t = m.ticks_per_beat();
return BarBeat { bar: bar + bars, beat: within / beat_t + 1, tick: within % beat_t };
}
BarBeat { bar, beat: 1, tick: 0 }
}
/// Bar|beat|tick → tick.
pub fn tick_at_bar_beat(&self, bb: BarBeat) -> i64 {
let start = self.bar_start_tick(bb.bar);
let m = self.meter_at_tick(start);
start.saturating_add(bb.beat.saturating_sub(1).max(0).saturating_mul(m.ticks_per_beat())).saturating_add(bb.tick)
}
pub fn bar_beat_at(&self, s: Samples, sr: SampleRate) -> BarBeat {
self.bar_beat_at_tick(self.samples_to_ticks(s, sr))
}
pub fn samples_at_bar_beat(&self, bb: BarBeat, sr: SampleRate) -> Samples {
self.tick_to_samples(self.tick_at_bar_beat(bb), sr)
}
/// Scale every tempo by `factor` (Tempo Operations › Scale).
pub fn scale_tempos(&mut self, factor: f64) -> Result<(), TimeError> {
let mut out = self.tempos.clone();
for e in &mut out {
e.bpm = valid_bpm(e.bpm * factor)?;
}
self.tempos = out;
self.reindex();
Ok(())
}
}
/// Ceiling division for a non-negative `num` and positive `den`, without overflow.
fn ceil_div(num: i64, den: i64) -> i64 {
num / den + i64::from(num % den != 0)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn default_map_is_120_4_4() {
let m = TempoMap::default();
let sr = SampleRate::HZ_48000;
// One bar of 4/4 at 120 bpm = 2 seconds.
assert_eq!(m.samples_at_bar_beat(BarBeat { bar: 2, beat: 1, tick: 0 }, sr), 96_000);
assert_eq!(m.bar_beat_at(96_000, sr), BarBeat { bar: 2, beat: 1, tick: 0 });
assert_eq!(m.bar_beat_at(24_000, sr), BarBeat { bar: 1, beat: 2, tick: 0 });
}
#[test]
fn meter_near_max_tick_does_not_overflow() {
let mut m = TempoMap::default();
m.set_meter(i64::MAX, 7, 8).unwrap();
let bb = m.bar_beat_at_tick(i64::MAX);
assert!(bb.bar > 1);
assert!(m.bar_start_tick(bb.bar) > 0);
assert!(m.bar_start_tick(i64::MAX) > 0);
}
#[test]
fn minimum_bar_saturates_instead_of_overflowing() {
let m = TempoMap::default();
assert_eq!(m.bar_start_tick(i64::MIN), i64::MIN);
assert_eq!(m.bar_start_tick(0), -TICKS_PER_QUARTER * 4);
}
#[test]
fn minimum_beat_is_clamped_without_overflow() {
let m = TempoMap::default();
assert_eq!(m.tick_at_bar_beat(BarBeat { bar: 1, beat: i64::MIN, tick: 0 }), 0);
}
#[test]
fn bar_beat_at_min_tick_does_not_overflow() {
let m = TempoMap::default();
let bb = m.bar_beat_at_tick(i64::MIN);
assert!(bb.bar < 1);
assert!(bb.beat >= 1 && bb.tick >= 0);
assert_eq!(m.bar_beat_at_tick(-1).bar, 0);
}
#[test]
fn tempo_change_is_integrated() {
let mut m = TempoMap::default();
let sr = SampleRate::HZ_48000;
m.set_tempo(TICKS_PER_QUARTER * 4, 60.0).unwrap();
// Bar 1 at 120 = 96000 samples; bar 2 at 60 = 192000 samples.
assert_eq!(m.tick_to_samples(TICKS_PER_QUARTER * 8, sr), 96_000 + 192_000);
assert_eq!(m.samples_to_ticks(96_000 + 192_000, sr), TICKS_PER_QUARTER * 8);
}
#[test]
fn meter_change_counts_bars() {
let mut m = TempoMap::default();
// 2 bars of 4/4 then 3/4.
m.set_meter(TICKS_PER_QUARTER * 8, 3, 4).unwrap();
assert_eq!(m.bar_beat_at_tick(TICKS_PER_QUARTER * 8), BarBeat { bar: 3, beat: 1, tick: 0 });
assert_eq!(m.bar_beat_at_tick(TICKS_PER_QUARTER * 11), BarBeat { bar: 4, beat: 1, tick: 0 });
assert_eq!(m.bar_start_tick(4), TICKS_PER_QUARTER * 11);
let bb = BarBeat { bar: 4, beat: 2, tick: 10 };
assert_eq!(m.bar_beat_at_tick(m.tick_at_bar_beat(bb)), bb);
}
#[test]
fn editing_an_early_meter_keeps_later_bars_and_scales_linearly() {
// 200 000 meter changes, one per bar, built directly: a walk per meter would take
// minutes to edit here, and even to build through `set_meter`.
let mut m = TempoMap::new(120.0, 4, 4).unwrap();
let mut tick = 0;
for bar in 1..200_000i64 {
let numerator = 3 + (bar % 2) as u32;
tick += m.meters.last().map_or(0, MeterEvent::ticks_per_bar);
m.meters.push(MeterEvent { tick, numerator, denominator: 4 });
}
let sample = [1usize, 1_000, 199_998];
let bars = |m: &TempoMap| sample.map(|i| m.bar_beat_at_tick(m.meters[i].tick).bar);
assert_eq!(bars(&m), [2, 1_001, 199_999]);
m.set_meter(0, 7, 8).unwrap();
assert_eq!(bars(&m), [2, 1_001, 199_999], "later meters keep their bar numbers");
assert_eq!(m.meters.len(), 200_000);
assert!(m.meters.windows(2).all(|w| w[0].tick < w[1].tick));
assert!(m.remove_meter(m.meters[1].tick));
assert_eq!([1usize, 999, 199_997].map(|i| m.bar_beat_at_tick(m.meters[i].tick).bar), [3, 1_001, 199_999]);
// Meters off their bar lines (counted as bars 2 and 4) land where they always did: on
// bar 2 of the new meter, then two bars of their own later.
let mut off = TempoMap::new(120.0, 4, 4).unwrap();
off.meters.push(MeterEvent { tick: 100, numerator: 3, denominator: 4 });
off.meters.push(MeterEvent { tick: 5_000, numerator: 5, denominator: 4 });
off.set_meter(0, 2, 4).unwrap();
let placed: Vec<(i64, u32)> = off.meters.iter().map(|e| (e.tick, e.numerator)).collect();
assert_eq!(placed, vec![(0, 2), (2 * TICKS_PER_QUARTER, 3), (8 * TICKS_PER_QUARTER, 5)]);
}
#[test]
fn eighth_note_meters() {
let m = TempoMap::new(120.0, 6, 8).unwrap();
assert_eq!(m.bar_beat_at_tick(TICKS_PER_QUARTER * 3), BarBeat { bar: 2, beat: 1, tick: 0 });
assert_eq!(m.bar_beat_at_tick(TICKS_PER_QUARTER / 2), BarBeat { bar: 1, beat: 2, tick: 0 });
}
#[test]
fn sanitize_keeps_what_the_setters_accept() {
let tempo = |tick, bpm| TempoEvent { tick, bpm };
let meter = |tick, numerator, denominator| MeterEvent { tick, numerator, denominator };
let mut m = TempoMap::from_parts(
vec![tempo(960, 4e9), tempo(3840, 90.0), tempo(-5, 100.0), tempo(1920, 60.0), tempo(1920, 70.0)],
vec![meter(0, 0, 4), meter(3840, 3, 4)],
);
m.sanitize();
assert_eq!(m.tempos(), [tempo(0, 60.0), tempo(1920, 60.0), tempo(3840, 90.0)]);
assert_eq!(m.meters(), [meter(0, 3, 4), meter(3840, 3, 4)]);
let mut empty = TempoMap::from_parts(Vec::new(), Vec::new());
empty.sanitize();
assert_eq!(empty, TempoMap::default());
}
#[test]
fn sanitize_drops_events_beyond_the_position_range() {
let mut m = TempoMap::from_parts(
vec![TempoEvent { tick: 0, bpm: 120.0 }, TempoEvent { tick: i64::MAX, bpm: 90.0 }],
vec![MeterEvent { tick: 0, numerator: 4, denominator: 4 }, MeterEvent { tick: i64::MAX, numerator: 3, denominator: 4 }],
);
m.sanitize();
assert_eq!(m, TempoMap::default());
assert_eq!(m.bar_start_tick(2), TICKS_PER_QUARTER * 4);
}
#[test]
fn rejects_bad_values() {
let mut m = TempoMap::default();
assert!(m.set_tempo(0, f64::NAN).is_err());
assert!(m.set_tempo(0, 0.0).is_err());
assert!(m.set_meter(0, 0, 4).is_err());
assert!(m.set_meter(0, 4, 3).is_err());
assert!(!m.remove_tempo(0));
}
#[test]
fn negative_positions() {
let m = TempoMap::default();
assert_eq!(m.bar_beat_at_tick(-TICKS_PER_QUARTER * 4), BarBeat { bar: 0, beat: 1, tick: 0 });
assert_eq!(m.bar_beat_at_tick(-TICKS_PER_QUARTER), BarBeat { bar: 0, beat: 4, tick: 0 });
}
/// The segment walk the indexed conversions replaced, kept as the reference they must match.
fn linear_tick_to_samples(m: &TempoMap, tick: i64, sr: SampleRate) -> i64 {
let mut acc = 0.0;
let mut seg_tick = 0i64;
let mut bpm = m.tempos[0].bpm;
for e in m.tempos.iter().skip(1) {
if e.tick >= tick {
break;
}
acc += (e.tick - seg_tick) as f64 * TempoMap::samples_per_tick(bpm, sr);
seg_tick = e.tick;
bpm = e.bpm;
}
to_samples(acc + (tick - seg_tick) as f64 * TempoMap::samples_per_tick(bpm, sr))
}
#[test]
fn indexed_conversions_match_the_segment_walk() {
let sr = SampleRate::HZ_48000;
let mut m = TempoMap::default();
for i in 1..200i64 {
m.set_tempo(i * 487, 40.0 + ((i * 37) % 300) as f64).unwrap();
}
assert!(m.remove_tempo(487 * 50));
for tick in (-1000..200 * 487 + 5000).step_by(113) {
let s = m.tick_to_samples(tick, sr);
assert_eq!(s, linear_tick_to_samples(&m, tick, sr), "tick {tick}");
assert!((m.samples_to_ticks(s, sr) - tick).abs() <= 1, "tick {tick}");
}
let json = serde_json::to_string(&m).unwrap();
assert!(!json.contains("elapsed"));
let back: TempoMap = serde_json::from_str(&json).unwrap();
assert_eq!(back, m);
assert_eq!(back.tick_to_samples(150 * 487 + 3, sr), m.tick_to_samples(150 * 487 + 3, sr));
}
#[test]
fn a_file_with_wild_ticks_is_indexed_without_overflow() {
let json = r#"{"tempos":[{"tick":0,"bpm":120.0},{"tick":-9223372036854775808,"bpm":100.0},{"tick":9223372036854775807,"bpm":90.0}],"meters":[{"tick":0,"numerator":4,"denominator":4}]}"#;
let mut m: TempoMap = serde_json::from_str(json).unwrap();
m.sanitize();
assert_eq!(m.tempos().first().map(|e| e.tick), Some(0));
assert_eq!(m.elapsed.len(), m.tempos().len());
}
#[test]
fn conversions_against_a_large_tempo_map_stay_fast() {
// 200k tempo changes, then 200k conversions each way: quadratic if every conversion walks
// the earlier segments, well under a second when it binary-searches them.
let sr = SampleRate::HZ_48000;
let n = 200_000i64;
let tempos = (0..n).map(|i| TempoEvent { tick: i * 10, bpm: 60.0 + (i % 7) as f64 }).collect();
let mut m = TempoMap::from_parts(tempos, vec![MeterEvent { tick: 0, numerator: 4, denominator: 4 }]);
m.sanitize();
let start = std::time::Instant::now();
let mut sum = 0i64;
for i in 0..n {
let s = m.tick_to_samples(i * 10 + 5, sr);
sum = sum.wrapping_add(s).wrapping_add(m.samples_to_ticks(s, sr));
}
assert!(sum != 0);
assert!(start.elapsed() < std::time::Duration::from_secs(5), "{:?}", start.elapsed());
}
}
+320
View File
@@ -0,0 +1,320 @@
//! SMPTE timecode and Feet+Frames.
use super::{SampleRate, Samples, TimeError};
/// Session timecode rates.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Default, serde::Serialize, serde::Deserialize)]
pub enum FrameRate {
Fps23_976,
#[default]
Fps24,
Fps25,
Fps29_97,
Fps29_97Drop,
Fps30,
Fps47_952,
Fps48,
Fps50,
Fps59_94,
Fps59_94Drop,
Fps60,
Fps100,
Fps119_88,
Fps120,
}
impl FrameRate {
pub const ALL: [FrameRate; 15] = [
FrameRate::Fps23_976,
FrameRate::Fps24,
FrameRate::Fps25,
FrameRate::Fps29_97,
FrameRate::Fps29_97Drop,
FrameRate::Fps30,
FrameRate::Fps47_952,
FrameRate::Fps48,
FrameRate::Fps50,
FrameRate::Fps59_94,
FrameRate::Fps59_94Drop,
FrameRate::Fps60,
FrameRate::Fps100,
FrameRate::Fps119_88,
FrameRate::Fps120,
];
/// Real frames per second as a rational (num, den).
pub fn rational(self) -> (i64, i64) {
match self {
FrameRate::Fps23_976 => (24_000, 1001),
FrameRate::Fps24 => (24, 1),
FrameRate::Fps25 => (25, 1),
FrameRate::Fps29_97 | FrameRate::Fps29_97Drop => (30_000, 1001),
FrameRate::Fps30 => (30, 1),
FrameRate::Fps47_952 => (48_000, 1001),
FrameRate::Fps48 => (48, 1),
FrameRate::Fps50 => (50, 1),
FrameRate::Fps59_94 | FrameRate::Fps59_94Drop => (60_000, 1001),
FrameRate::Fps60 => (60, 1),
FrameRate::Fps100 => (100, 1),
FrameRate::Fps119_88 => (120_000, 1001),
FrameRate::Fps120 => (120, 1),
}
}
/// Frames per labelled second (the counting base).
pub fn nominal(self) -> i64 {
let (n, d) = self.rational();
(n + d / 2) / d
}
pub fn is_drop(self) -> bool {
matches!(self, FrameRate::Fps29_97Drop | FrameRate::Fps59_94Drop)
}
pub fn label(self) -> &'static str {
match self {
FrameRate::Fps23_976 => "23.976",
FrameRate::Fps24 => "24",
FrameRate::Fps25 => "25",
FrameRate::Fps29_97 => "29.97",
FrameRate::Fps29_97Drop => "29.97 Drop",
FrameRate::Fps30 => "30",
FrameRate::Fps47_952 => "47.952",
FrameRate::Fps48 => "48",
FrameRate::Fps50 => "50",
FrameRate::Fps59_94 => "59.94",
FrameRate::Fps59_94Drop => "59.94 Drop",
FrameRate::Fps60 => "60",
FrameRate::Fps100 => "100",
FrameRate::Fps119_88 => "119.88",
FrameRate::Fps120 => "120",
}
}
/// Whole frame index at a sample position (floored).
pub fn frames_at(self, s: Samples, sr: SampleRate) -> i64 {
let (n, d) = self.rational();
let num = i128::from(s) * i128::from(n);
let den = i128::from(sr.hz()) * i128::from(d);
if den == 0 {
return 0;
}
i64::try_from(num.div_euclid(den)).unwrap_or(0)
}
/// Sample position of the start of frame `f`.
pub fn samples_at_frame(self, f: i64, sr: SampleRate) -> Samples {
let (n, d) = self.rational();
if n == 0 {
return 0;
}
// Use the first integer sample whose floored frame lookup is `f`; nearest rounding
// can put fractional-rate frame starts just before their own lookup interval.
let numerator = i128::from(f) * i128::from(sr.hz()) * i128::from(d);
let denominator = i128::from(n);
let floor = numerator.div_euclid(denominator);
let v = floor + i128::from(numerator.rem_euclid(denominator) != 0);
i64::try_from(v).unwrap_or(if v < 0 { i64::MIN } else { i64::MAX })
}
/// Samples per frame (fractional).
pub fn samples_per_frame(self, sr: SampleRate) -> f64 {
let (n, d) = self.rational();
sr.as_f64() * d as f64 / n as f64
}
}
/// A timecode label.
#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
pub struct Timecode {
pub negative: bool,
pub hours: i64,
pub minutes: i64,
pub seconds: i64,
pub frames: i64,
}
impl Timecode {
/// Label for an absolute frame count.
pub fn from_frames(frames: i64, rate: FrameRate) -> Timecode {
let negative = frames < 0;
// Drop-frame labels can exceed i64::MAX even when the frame count fits. Widen before
// taking the magnitude so i64::MIN is represented exactly too.
let mut f = i128::from(frames).abs();
let base = i128::from(rate.nominal().max(1));
if rate.is_drop() {
// Drop 2 (or 4) labels each minute except every tenth.
let drop = if base >= 60 { 4 } else { 2 };
let per_10min = base * 600 - drop * 9;
let per_min = base * 60 - drop;
let tens = f / per_10min;
let rem = f % per_10min;
let extra = if rem > drop { drop * 9 * tens + drop * ((rem - drop) / per_min) } else { drop * 9 * tens };
f += extra;
}
let frames_l = f % base;
let total_secs = f / base;
Timecode {
negative,
hours: i64::try_from(total_secs / 3600).unwrap_or(i64::MAX),
minutes: i64::try_from((total_secs / 60) % 60).unwrap_or(0),
seconds: i64::try_from(total_secs % 60).unwrap_or(0),
frames: i64::try_from(frames_l).unwrap_or(0),
}
}
/// Absolute frame count of this label.
pub fn to_frames(&self, rate: FrameRate) -> i64 {
let base = rate.nominal().max(1);
let total_min = i128::from(self.hours) * 60 + i128::from(self.minutes);
let mut f = (total_min * 60 + i128::from(self.seconds)) * i128::from(base) + i128::from(self.frames);
if rate.is_drop() {
let drop = if base >= 60 { 4 } else { 2 };
f -= drop * (total_min - total_min / 10);
}
let f = if self.negative { -f } else { f };
i64::try_from(f).unwrap_or(if f < 0 { i64::MIN } else { i64::MAX })
}
pub fn from_samples(s: Samples, rate: FrameRate, sr: SampleRate) -> Timecode {
Timecode::from_frames(rate.frames_at(s, sr), rate)
}
pub fn to_samples(&self, rate: FrameRate, sr: SampleRate) -> Samples {
rate.samples_at_frame(self.to_frames(rate), sr)
}
pub fn format(&self, rate: FrameRate) -> String {
let sep = if rate.is_drop() { ';' } else { ':' };
let sign = if self.negative { "-" } else { "" };
format!("{sign}{:02}:{:02}:{:02}{sep}{:02}", self.hours, self.minutes, self.seconds, self.frames)
}
/// Parse `HH:MM:SS:FF` (also `;` / `.` separators and fewer fields, right-aligned).
pub fn parse(text: &str) -> Result<Timecode, TimeError> {
let t = text.trim();
let (negative, t) = match t.strip_prefix('-') {
Some(r) => (true, r),
None => (false, t),
};
if t.len() > 80 {
return Err(TimeError::Parse("timecode is too long".into(), "timecode"));
}
if t.bytes().filter(|b| matches!(b, b':' | b';' | b'.')).count() > 3 {
return Err(TimeError::Parse(text.to_string(), "timecode"));
}
let parts: Vec<&str> = t.split([':', ';', '.']).collect();
if parts.is_empty() || parts.len() > 4 {
return Err(TimeError::Parse(text.to_string(), "timecode"));
}
let mut nums = [0i64; 4];
let offset = 4 - parts.len();
for (i, p) in parts.iter().enumerate() {
let v: i64 = p.trim().parse().map_err(|_| TimeError::Parse(text.to_string(), "timecode"))?;
if v < 0 {
return Err(TimeError::Parse(text.to_string(), "timecode"));
}
if let Some(slot) = nums.get_mut(offset + i) {
*slot = v;
}
}
Ok(Timecode { negative, hours: nums[0], minutes: nums[1], seconds: nums[2], frames: nums[3] })
}
}
/// Feet+Frames for 35 mm film (16 frames per foot), `start` frames in at sample 0.
pub fn feet_frames(s: Samples, rate: FrameRate, sr: SampleRate, start: i64) -> (i64, i64) {
let f = rate.frames_at(s, sr).saturating_add(start);
(f.div_euclid(16), f.rem_euclid(16))
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn non_drop_round_trip() {
let sr = SampleRate::HZ_48000;
let tc = Timecode::from_samples(48_000 * 3661, FrameRate::Fps25, sr);
assert_eq!(tc.format(FrameRate::Fps25), "01:01:01:00");
assert_eq!(tc.to_samples(FrameRate::Fps25, sr), 48_000 * 3661);
}
#[test]
fn fractional_frame_starts_match_floored_lookup_on_both_sides_of_zero() {
let sr = SampleRate::HZ_48000;
let tc = Timecode::parse("-00:00:01:00").unwrap();
assert_eq!(tc.to_samples(FrameRate::Fps25, sr), -48_000);
for r in [FrameRate::Fps25, FrameRate::Fps29_97, FrameRate::Fps29_97Drop, FrameRate::Fps23_976] {
for f in [-107_892i64, -25, -7, -1, 0, 1, 2, 7, 25, 1799, 107_892] {
assert_eq!(r.frames_at(r.samples_at_frame(f, sr), sr), f, "{r:?} frame {f}");
}
}
assert_eq!(FrameRate::Fps29_97.frames_at(FrameRate::Fps29_97.samples_at_frame(2, sr), sr), 2);
}
#[test]
fn drop_frame_labels() {
let r = FrameRate::Fps29_97Drop;
// Frame 1800 is the first frame of minute 1 → labelled 00:01:00;02.
assert_eq!(Timecode::from_frames(1800, r).format(r), "00:01:00;02");
assert_eq!(Timecode::from_frames(1799, r).format(r), "00:00:59;29");
// Minute 10 keeps its labels.
assert_eq!(Timecode::from_frames(17_982, r).format(r), "00:10:00;00");
for f in [0, 1, 1799, 1800, 1801, 17_981, 17_982, 107_892, 500_000] {
assert_eq!(Timecode::from_frames(f, r).to_frames(r), f, "frame {f}");
}
}
#[test]
fn huge_timecode_saturates_instead_of_overflowing() {
let sr = SampleRate::HZ_48000;
for r in [FrameRate::Fps30, FrameRate::Fps29_97Drop] {
let tc = Timecode::parse("9223372036854775807:00:00:00").unwrap();
assert_eq!(tc.to_samples(r, sr), i64::MAX);
let tc = Timecode::parse("-9223372036854775807:00:00:00").unwrap();
assert_eq!(tc.to_samples(r, sr), i64::MIN);
}
let tc = Timecode::parse("01:02:03:04").unwrap();
assert_eq!(tc.to_samples(FrameRate::Fps30, sr), 178_710_400);
}
#[test]
fn extreme_frame_counts_round_trip_at_every_rate() {
for rate in FrameRate::ALL {
for frames in [i64::MIN, i64::MIN + 1, -1, 0, 1, i64::MAX - 1, i64::MAX] {
let tc = Timecode::from_frames(frames, rate);
assert_eq!(tc.to_frames(rate), frames, "{rate:?}: {frames}");
assert_eq!(tc.negative, frames < 0);
assert!((0..60).contains(&tc.minutes));
assert!((0..60).contains(&tc.seconds));
assert!((0..rate.nominal()).contains(&tc.frames));
assert_eq!(Timecode::parse(&tc.format(rate)).unwrap(), tc);
}
}
}
#[test]
fn extreme_drop_frame_labels_do_not_overflow() {
for rate in [FrameRate::Fps29_97Drop, FrameRate::Fps59_94Drop] {
assert_eq!(Timecode::from_frames(i64::MAX, rate).to_frames(rate), i64::MAX);
}
}
#[test]
fn parse_variants() {
let tc = Timecode::parse("01:02:03:04").unwrap();
assert_eq!((tc.hours, tc.minutes, tc.seconds, tc.frames), (1, 2, 3, 4));
let tc = Timecode::parse("3:04").unwrap();
assert_eq!((tc.seconds, tc.frames), (3, 4));
assert!(Timecode::parse("a:b").is_err());
assert!(Timecode::parse("1:2:3:4:5").is_err());
assert!(Timecode::parse(&format!("{}:", "1".repeat(100_000))).is_err());
}
#[test]
fn feet_and_frames() {
let sr = SampleRate::HZ_48000;
assert_eq!(feet_frames(48_000, FrameRate::Fps24, sr, 0), (1, 8));
}
}
+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));
}
}
}
}
+1
View File
@@ -20,6 +20,7 @@ otio = ["dep:serde_json"]
[dependencies]
serde = { workspace = true, optional = true }
craft-time.workspace = true
craft-xml = { workspace = true, optional = true }
serde_json = { workspace = true, optional = true }
+16 -616
View File
@@ -1,627 +1,27 @@
//! Exact media time.
//! Compatibility re-exports for timeline time primitives.
//!
//! 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, 120 …) and every common audio sample duration (8 k … 192 kHz, including the 44.1 k
//! family), so edits, frame math and audio alignment never drift.
//!
//! Timecode (SMPTE drop/non-drop, frames, feet+frames, samples) is only a *display* of ticks.
//!
//! Every conversion is total: a zero or negative rate (from a damaged file or project) gives
//! zero instead of a division-by-zero panic, and an invalid [`FrameRate`] counts as the default.
//! The implementation lives in `craft-time`; this module keeps the established
//! `craft_timeline::time::*` paths available to downstream users.
use std::fmt;
use std::ops::{Add, AddAssign, Neg, Sub, SubAssign};
pub use craft_time::{
FrameRate, ParseError, SAMPLE_RATES, TICKS_PER_SECOND, Tick, TimeDisplay, TimeRange, fields_to_frames, format_timecode_frames, frames_to_fields,
parse_time_display_relative as parse_time_display, parse_timecode_relative as parse_timecode,
};
/// Ticks per second.
pub const TICKS_PER_SECOND: i64 = 254_016_000_000;
/// A point or duration in time, in ticks.
/// Arithmetic saturates at the i64 storage limits; representable results remain exact.
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, PartialOrd, Ord, Hash)]
#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
#[cfg_attr(feature = "serde", 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);
pub fn from_seconds_f64(s: f64) -> Tick {
Tick((s * TICKS_PER_SECOND as f64).round() as i64)
}
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 {
// A product beyond i128 divided by an i64 cannot fit back into i64.
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).
///
/// For finding a sample by its timestamp: containers store timestamps rounded to their
/// timebase (Matroska usually to 1 ms), so a stamp can sit up to half a unit after the true
/// time. Flooring the requested time misses every frame whose stamp was rounded 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 };
// 0 <= r < unit: round up from the halfway point
clamp_i64(if r * 2 >= unit { q + 1 } else { q })
}
pub fn abs(self) -> Tick {
Tick(self.0.saturating_abs())
}
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 {
// max/min rather than `clamp`, which panics when the bounds cross.
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)))
}
}
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);
}
}
/// A half-open time range `[start, start + duration)`.
/// Zero or negative durations are empty and never overlap another range.
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Hash)]
#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::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.is_empty() && !o.is_empty() && 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)]
#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::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 };
/// Rates offered in sequence settings (Premiere's list).
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 float (e.g. from a container's average 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)
}
/// Closest standard rate when `fps` is within `tolerance` of it, `tolerance` a fraction of the
/// rate (`0.005` is half a percent), else the nearest 1/1000 rate. A rate a container reports
/// from timestamps rounded to a coarse unit lands slightly off a standard rate: at 90 kHz,
/// whole milliseconds leave a 30 fps clip's average at 30.02 or 29.95 (#1700).
pub fn from_f64_within(fps: f64, tolerance: f64) -> Self {
let mut best: Option<(f64, FrameRate)> = None;
for r in Self::COMMON {
let off = (r.as_f64() - fps).abs();
if off <= tolerance * r.as_f64() && best.is_none_or(|(closest, _)| off < closest) {
best = Some((off, r));
}
}
best.map_or_else(|| Self::from_f64(fps), |(_, r)| r)
}
/// This rate if it is positive, else the default (a damaged file or project can carry a zero
/// or negative rate; every frame computation uses this so it can't divide by zero).
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 (rounded down only for exotic rates).
/// Values outside the integer tick range saturate at its limit.
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), saturated to the integer frame range.
pub fn frame_at(self, t: Tick) -> i64 {
clamp_i64(frame_index(self.sane(), t))
}
/// Frames from `start` to `end`, counted between their frame indices. `frame_at(end - start)`
/// can read one short when a frame is not a whole number of ticks: the ticks between two
/// boundaries can be one fewer than the exact length of those frames.
pub fn frames_between(self, start: Tick, end: Tick) -> i64 {
self.frame_at(end).saturating_sub(self.frame_at(start))
}
/// Start tick of frame `f` (ceiling division).
///
/// Uses ceiling (not floor) so that `frame_at(tick_of(f)) == f` holds for every
/// representable frame boundary with at least one tick per frame, even when the frame duration is not an integer number of ticks
/// (exotic rates like 37.516 fps where `TICKS_PER_SECOND * den / num` has a
/// fractional remainder). Floor division truncates the remainder, causing each
/// frame boundary to drift backward by one tick until `frame_at` returns `f - 1`
/// instead of `f` — frame stepping then stalls or moves backward.
/// Values outside the integer tick range saturate at its limit.
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();
// Keep the frame index and both boundaries wide until choosing the nearest. Saturating
// an unrepresentable upper boundary first would make it seem closer than it really is.
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 (frames counted per timecode 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)
}
/// NTSC (x/1001) rates can use drop-frame timecode.
pub fn is_ntsc(self) -> bool {
self.den == 1001
}
/// Whether drop-frame counting applies (29.97 / 59.94 / 119.88).
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}");
let s = s.trim_end_matches('0').trim_end_matches('.');
s.to_string()
}
}
}
impl fmt::Display for FrameRate {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
write!(f, "{} fps", self.label())
}
}
/// Narrow only after applying the storage bounds, never by a wrapping integer cast.
fn clamp_i64(value: i128) -> i64 {
value.clamp(i128::from(i64::MIN), i128::from(i64::MAX)) as i64
}
/// Callers pass a positive rate. Products of two i64 values fit in i128.
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))
}
/// Ceiling of the rational boundary. An overflowing numerator divided by an i64 rate cannot
/// fit in an i64 tick, so saturating that intermediate preserves the public result. Boundaries
/// derived from frame_index remain within i128 (at most one frame away from an i64 tick).
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);
// Flooring a fractional boundary puts the tick in the preceding frame.
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 displayed (Premiere: Timecode, Feet+Frames 16mm/35mm, Frames, Audio Samples).
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Hash)]
#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::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",
}
}
}
impl TimeDisplay {
/// The video display formats (Sequence Settings ▸ Display Format, the burn-in's Time Format).
pub const VIDEO: [TimeDisplay; 4] = [TimeDisplay::Timecode, TimeDisplay::Feet35, TimeDisplay::Feet16, TimeDisplay::Frames];
/// Stable id used by commands and the UI: `timecode`, `feet35`, `feet16`, `frames`, `samples`, `seconds`.
pub fn id(self) -> &'static str {
match self {
TimeDisplay::Timecode => "timecode",
TimeDisplay::Frames => "frames",
TimeDisplay::Feet16 => "feet16",
TimeDisplay::Feet35 => "feet35",
TimeDisplay::AudioSamples => "samples",
TimeDisplay::Seconds => "seconds",
}
}
/// The display with this [`id`](Self::id), ignoring case.
pub fn from_id(id: &str) -> Option<TimeDisplay> {
TimeDisplay::ALL.into_iter().find(|d| d.id().eq_ignore_ascii_case(id))
}
/// Frames per foot of film for the Feet + Frames displays: 16 for 35mm 4-perf, 40 for 16mm.
pub fn frames_per_foot(self) -> Option<i64> {
match self {
TimeDisplay::Feet35 => Some(16),
TimeDisplay::Feet16 => Some(40),
_ => None,
}
}
}
/// Format a frame count as film feet + frames (`F+FF`, e.g. `2+08`; negative: `-2+08`).
/// `frames_per_foot` below 1 is treated as 1 so a bad value never divides by zero.
/// Format a timeline frame count as film feet + frames, retaining its established compact display.
pub fn format_feet_frames(frame: i64, frames_per_foot: i64) -> String {
let per_ft = frames_per_foot.max(1).unsigned_abs();
let a = frame.unsigned_abs();
format!("{}{}+{:02}", if frame < 0 { "-" } else { "" }, a / per_ft, a % per_ft)
let per_foot = frames_per_foot.max(1).unsigned_abs();
let frames = frame.unsigned_abs();
format!("{}{}+{:02}", if frame < 0 { "-" } else { "" }, frames / per_foot, frames % per_foot)
}
/// Drop-frame parameters: frames dropped per minute and nominal base.
fn df_params(rate: FrameRate) -> (i64, i64) {
let base = rate.timecode_base();
(base / 15, base) // 30 → 2, 60 → 4, 120 → 8
}
/// 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));
}
/// Format time using the timeline's historical compact Feet + Frames display.
pub fn format_time(tick: Tick, rate: FrameRate, drop_frame: bool, display: TimeDisplay, sample_rate: i64) -> String {
if let TimeDisplay::Feet35 | TimeDisplay::Feet16 = display {
return format_feet_frames(rate.frame_at(tick), display.frames_per_foot().unwrap_or(16));
}
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))
craft_time::format_time(tick, rate, drop_frame, display, sample_rate)
}
/// 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);
// Nominal drop-frame labels can exceed i64 even when the final frame count fits.
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 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 => format_feet_frames(frame, display.frames_per_foot().unwrap_or(16)),
TimeDisplay::AudioSamples => {
let neg = t.0 < 0;
let a = t.0.unsigned_abs();
let tps = TICKS_PER_SECOND.unsigned_abs();
let secs = a / tps;
let rem = Tick((a % tps) as i64).to_units_floor(sample_rate);
format!("{}{:02}:{:02}:{:02}:{rem:05}", if neg { "-" } else { "" }, secs / 3600, (secs / 60) % 60, secs % 60)
}
TimeDisplay::Seconds => {
let s = format!("{:.3}", t.seconds());
// A tiny negative time rounds to zero but keeps its sign: drop it.
if s == "-0.000" { "0.000".to_string() } else { s }
}
}
}
/// Error from [`parse_timecode`].
#[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, the way Premiere's timecode fields do:
/// - `01:02:03:04`, `01;02;03;04`, `1.2.3.4` (any of `:;.,` separators),
/// - bare digits are read right-aligned as `HHMMSSFF` (`1000` = 00:00:10:00),
/// - a leading `+`/`-` makes the value relative to `current` (`+15` = 15 frames later, `-1.00` = 1 s earlier).
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}`")));
}
// Right-aligned pairs: "12345" -> [1, 23, 45]; relative bare numbers are frames.
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 if !p.bytes().all(|b| b.is_ascii_digit()) {
Err(ParseError(format!("bad field `{p}`")))
} 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;
// Overflowing fields (e.g. 90 frames) are allowed, as in Premiere.
let base = rate.timecode_base();
let frames = if nums.len() == 1 && rel.is_some() {
fr
} else if drop_frame && rate.supports_drop_frame() {
// overflowing fields carry into the next one first, then drop-frame counting applies
let (sec, fr) = (sec.saturating_add(fr / base), fr % base);
let (m, sec) = (m.saturating_add(sec / 60), sec % 60);
let (h, m) = (h.saturating_add(m / 60), m % 60);
fields_to_frames(h, m, sec, fr, rate, true)
} else {
// Saturating: typed or agent-sent timecodes can have absurdly large fields.
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 typed text in the given display format into a frame count.
///
/// - [`TimeDisplay::Frames`]: a plain frame count (`240`).
/// - [`TimeDisplay::Feet35`] / [`TimeDisplay::Feet16`]: `F+FF` (`2+08`; `.`, `:`, `;` and `,` also
/// separate); a bare number is a frame count. A frames field past the foot rolls over
/// (`1+20` at 35mm is 36 frames), as the timecode parser does.
/// - A leading `+` / `-` makes the value relative to `current` (a bare relative number is frames).
/// - Every other display parses as timecode ([`parse_timecode`]).
pub fn parse_time_display(input: &str, rate: FrameRate, drop_frame: bool, display: TimeDisplay, current: i64) -> Result<i64, ParseError> {
let per_ft = match display {
TimeDisplay::Frames => 1,
TimeDisplay::Feet35 | TimeDisplay::Feet16 => display.frames_per_foot().unwrap_or(16),
_ => return parse_timecode(input, rate, drop_frame, current),
};
let s = input.trim();
if s.is_empty() {
return Err(ParseError("empty time".into()));
}
let (rel, body) = match s.as_bytes()[0] {
b'+' => (Some(1), &s[1..]),
b'-' => (Some(-1), &s[1..]),
_ => (None, s),
};
let field = |p: &str| -> Result<i64, ParseError> {
if p.is_empty() {
Ok(0)
} else if !p.bytes().all(|b| b.is_ascii_digit()) {
Err(ParseError(format!("not a time: `{input}`")))
} else {
p.parse::<i64>().map_err(|_| ParseError("number too large".into()))
}
};
let parts: Vec<&str> = body.split(['+', ':', ';', '.', ',']).collect();
let frames = match parts.as_slice() {
[n] => field(n)?,
[feet, fr] if per_ft > 1 => field(feet)?.saturating_mul(per_ft).saturating_add(field(fr)?),
_ => return Err(ParseError(format!("not a time: `{input}`"))),
};
Ok(match rel {
Some(sign) => current.saturating_add(frames.saturating_mul(sign)),
None => frames,
})
}
/// 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;
+4
View File
@@ -1,6 +1,10 @@
use super::*;
use proptest::prelude::*;
fn clamp_i64(value: i128) -> i64 {
value.clamp(i128::from(i64::MIN), i128::from(i64::MAX)) as i64
}
#[test]
fn empty_time_ranges_never_overlap() {
let clip = TimeRange::new(Tick(0), Tick(10));