Exact-integer rational time types for media pipelines — FFmpeg-style Timebase, Timestamp, and TimeRange for Rust. no_std by default, zero dependencies, const fn throughout.
Overview
mediatime provides the same three primitives every media pipeline reinvents, done once with integer-exact semantics:
Timebase— a rationalnum/den(bothi32; the constructor requiresnum >= 0andden > 0). Signed to match FFmpeg'sAVRational, which is a pair of Cints: au32numerator or denominator abovei32::MAXis representable but cannot round-trip into anAVRational, andi32is also a nativeINTEGERon PostgreSQL, MySQL and SQLite. Common values:1/1000(ms PTS),1/90000(MPEG-TS),30000/1001(NTSC frame rate).Timestamp— ani64PTS tagged with aTimebase. Two timestamps compare by the instant they represent, not by their raw(pts, timebase)tuple, soTimestamp(1_000, 1/1000)equalsTimestamp(90_000, 1/90_000). Cross-timebase comparison uses a 128-bit cross-multiply — no division, no rounding.TimeRange— a half-open[start, end)interval sharing a singleTimebase. Carries the endpoints as raw PTS; returnsTimestampon demand.
Everything is const fn. The crate only uses core — no allocation, no dependencies. Use it as the time layer for scene detectors, demuxers, NLE timelines, or anywhere you'd otherwise pass f64 seconds around and pay for rounding drift later.
Why not f64 seconds?
Floating-point seconds accumulate drift: 0.1 + 0.2 != 0.3. Real video timestamps are already integer PTS in an integer timebase — converting to f64 for arithmetic only to convert back on output introduces rounding error. mediatime keeps the representation that the stream actually carries, and does exact rational arithmetic on it.
Equality semantics show the win:
f64 seconds: 0.1 + 0.2 == 0.3 → false
mediatime::Timestamp: 100 ms == 9000 ticks @ 1/90000 → true
Features
- Value-based equality and ordering on the instants and the rationals.
1/2 == 2/4 == 3/6;Timestamp(1000, 1/1000) == Timestamp(90_000, 1/90_000). Cross-timebasecmpuses 128-bit cross-multiply — exact for anyi32numerator/denominator with anyi64PTS. Spans and ranges are compared as written instead, and carry noOrdat all; each type's docs say which it is and why. - Hash agrees with Eq. Hashes the reduced-form rational, so equal rationals hash identically and you can use these types as
HashMapkeys. - FFmpeg-style utilities.
checked_rescale/saturating_rescale(a.k.a.av_rescale_q, rounding to nearest with halfway cases away from zero, as FFmpeg'sAV_ROUND_NEAR_INFdoes),checked_duration_to_pts/checked_pts_to_duration,duration_since,saturating_sub_duration. Every lossy conversion is spelledchecked_orsaturating_— there is no bare name whose overflow posture you have to remember. - Rates are their own type.
Rateis a timebase read the other way round — events per second rather than seconds per tick — so a frame rate cannot reachav_rescale_qas a timebase by accident. It knows how long n frames take (checked_frames_to_duration), carries its own roster (Rate::FPS_29_97,FPS_23_976, …), and converts both ways withto_timebase/from_timebase. Its eight rates areTimebase's eight frame intervals reciprocated, entry for entry — a test pins the bijection, so neither roster can grow a frame rate without the other. - Signed spans.
SignedDurationis what the difference of two instants actually is —later.signed_duration_since(&earlier)— andDurationcannot hold it, being unsigned. It shifts an instant back again (ts.checked_add_signed(span),saturating_sub_signed), adds and subtracts across timebases, and answers in the left operand's. Sorting by length is asked for by name —spans.sort_by(SignedDuration::cmp_semantic)— because2 @ 1/1and1000 @ 1/1000are one second apart in length and the counts say the opposite. - Named timebases. Twenty-seven in three families: the clock subdivisions (
SECONDS,MILLIS,MICROS,NANOS,MPEG_90K), fourteen audio sample intervals (HZ_8K…HZ_192K), and eight frame intervals (NTSC_FILM,FILM_24,PAL_25,NTSC_VIDEO,VIDEO_30,PAL_50,NTSC_60,VIDEO_60) — each with the container or codec convention that declares it.Timebase::from_name("MPEG_90K")reads a name — in any ASCII case, so"mpeg_90k"reads too —well_known_name()writes the canonical spelling back, andFromStraccepts either a name ornum/den. One value, one name: the roster holds the values a convention travels with, so Matroska's and FLV's millisecond bases are bothMILLISwith no alias beside it, while an MP4/MOV timescale — chosen per file by the muxer — carries no convention to name and stays the rational it is. TimeRangeinterpolation. Linear midpoint (interpolate(t)) for placing an event somewhere between fade-out and fade-in frames, witht ∈ [0, 1]clamped.Displayfor logs.{}is readable where there is a readable form —0:00:00.137,[0:00:01.500, 0:00:03.250)— and{:#}is exact:12345 @ 1/90000,[1500, 3250) @ 1/1000. A rational, a rate and a span have nothing to expand into, so their one rendering is exact in both:1/1000,30000/1001,-1500 @ 1/1000.FromStrfor the exact form. All five types read back the value that wrote them, each rejecting with its own error. The readable clock has no inverse — it is truncated to milliseconds and names no timebase — so it is rejected rather than guessed at. Roster names read on the input side only, and only on their own door:"MILLIS"is a timebase,"FPS_24"is a rate, and neither parses as the other, a rate being the reciprocal reading of a rational rather than a second spelling of it.no_std+no_alloclibrary. The library builds withoutstdandalloc; tests usestd.const fnthroughout. BuildTimebase/Timestamp/TimeRangeinconstcontext.
Example
use NonZeroI32;
use Duration;
use ;
// FFmpeg-style rational timebases — spelled out, or taken from the roster.
let ms = new;
let mpegts = new;
assert_eq!;
assert_eq!;
assert_eq!;
// Same instant in two different timebases — they compare equal.
let a = new;
let b = new;
assert_eq!;
assert_eq!;
// `av_rescale_q`-style conversion, rounding to the nearest tick.
assert_eq!;
assert_eq!;
// Point minus point is a vector: the difference of two instants is signed,
// and shifting an instant by one crosses timebases on the way.
let span = b.signed_duration_since;
assert_eq!; // half a second, on the MPEG clock
assert_eq!;
// A frame rate is its own type: the reciprocal reading, and it knows how
// long n frames take.
let ntsc = fps;
assert_eq!;
assert_eq!;
assert_eq!;
// A half-open [start, end) range with interpolation.
let r = new;
assert_eq!;
assert_eq!;
// `Display` renders for humans; `{:#}` renders the exact stored value.
assert_eq!;
assert_eq!;
assert_eq!;
assert_eq!;
assert_eq!;
assert_eq!;
assert_eq!;
// `FromStr` inverts the exact form, and also reads a roster name.
assert_eq!;
assert_eq!;
assert_eq!;
assert_eq!;
assert_eq!;
// Each roster stays on its own door: a rate is not a timebase.
assert!;
assert!;
Installation
[]
= "0.3"
MSRV
Rust 1.85.
License
mediatime is under the terms of both the MIT license and the
Apache License (Version 2.0).
See LICENSE-APACHE, LICENSE-MIT for details.
Copyright (c) 2026 FinDIT Studio authors.