<div align="center">
<h1>mediatime</h1>
</div>
<div align="center">
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.
[<img alt="github" src="https://img.shields.io/badge/github-findit--studio/mediatime-8da0cb?style=for-the-badge&logo=Github" height="22">][Github-url]
<img alt="LoC" src="https://img.shields.io/endpoint?url=https%3A%2F%2Fgist.githubusercontent.com%2Fal8n%2F327b2a8aef9003246e45c6e47fe63937%2Fraw%2Fmediatime" height="22">
[<img alt="Build" src="https://img.shields.io/github/actions/workflow/status/findit-studio/mediatime/ci.yml?logo=Github-Actions&style=for-the-badge" height="22">][CI-url]
[<img alt="codecov" src="https://img.shields.io/codecov/c/gh/findit-studio/mediatime?style=for-the-badge&token=6R3QFWRWHL&logo=codecov" height="22">][codecov-url]
[<img alt="docs.rs" src="https://img.shields.io/badge/docs.rs-mediatime-66c2a5?style=for-the-badge&labelColor=555555&logo=data:image/svg+xml;base64,PHN2ZyByb2xlPSJpbWciIHhtbG5zPSJodHRwOi8vd3d3LnczLm9yZy8yMDAwL3N2ZyIgdmlld0JveD0iMCAwIDUxMiA1MTIiPjxwYXRoIGZpbGw9IiNmNWY1ZjUiIGQ9Ik00ODguNiAyNTAuMkwzOTIgMjE0VjEwNS41YzAtMTUtOS4zLTI4LjQtMjMuNC0zMy43bC0xMDAtMzcuNWMtOC4xLTMuMS0xNy4xLTMuMS0yNS4zIDBsLTEwMCAzNy41Yy0xNC4xIDUuMy0yMy40IDE4LjctMjMuNCAzMy43VjIxNGwtOTYuNiAzNi4yQzkuMyAyNTUuNSAwIDI2OC45IDAgMjgzLjlWMzk0YzAgMTMuNiA3LjcgMjYuMSAxOS45IDMyLjJsMTAwIDUwYzEwLjEgNS4xIDIyLjEgNS4xIDMyLjIgMGwxMDMuOS01MiAxMDMuOSA1MmMxMC4xIDUuMSAyMi4xIDUuMSAzMi4yIDBsMTAwLTUwYzEyLjItNi4xIDE5LjktMTguNiAxOS45LTMyLjJWMjgzLjljMC0xNS05LjMtMjguNC0yMy40LTMzLjd6TTM1OCAyMTQuOGwtODUgMzEuOXYtNjguMmw4NS0zN3Y3My4zek0xNTQgMTA0LjFsMTAyLTM4LjIgMTAyIDM4LjJ2LjZsLTEwMiA0MS40LTEwMi00MS40di0uNnptODQgMjkxLjFsLTg1IDQyLjV2LTc5LjFsODUtMzguOHY3NS40em0wLTExMmwtMTAyIDQxLjQtMTAyLTQxLjR2LS42bDEwMi0zOC4yIDEwMiAzOC4ydi42em0yNDAgMTEybC04NSA0Mi41di03OS4xbDg1LTM4Ljh2NzUuNHptMC0xMTJsLTEwMiA0MS40LTEwMi00MS40di0uNmwxMDItMzguMiAxMDIgMzguMnYuNnoiPjwvcGF0aD48L3N2Zz4K" height="20">][doc-url]
[<img alt="crates.io" src="https://img.shields.io/crates/v/mediatime?style=for-the-badge&logo=data:image/svg+xml;base64,PD94bWwgdmVyc2lvbj0iMS4wIiBlbmNvZGluZz0iaXNvLTg4NTktMSI/Pg0KPCEtLSBHZW5lcmF0b3I6IEFkb2JlIElsbHVzdHJhdG9yIDE5LjAuMCwgU1ZHIEV4cG9ydCBQbHVnLUluIC4gU1ZHIFZlcnNpb246IDYuMDAgQnVpbGQgMCkgIC0tPg0KPHN2ZyB2ZXJzaW9uPSIxLjEiIGlkPSJMYXllcl8xIiB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHhtbG5zOnhsaW5rPSJodHRwOi8vd3d3LnczLm9yZy8xOTk5L3hsaW5rIiB4PSIwcHgiIHk9IjBweCINCgkgdmlld0JveD0iMCAwIDUxMiA1MTIiIHhtbDpzcGFjZT0icHJlc2VydmUiPg0KPGc+DQoJPGc+DQoJCTxwYXRoIGQ9Ik0yNTYsMEwzMS41MjgsMTEyLjIzNnYyODcuNTI4TDI1Niw1MTJsMjI0LjQ3Mi0xMTIuMjM2VjExMi4yMzZMMjU2LDB6IE0yMzQuMjc3LDQ1Mi41NjRMNzQuOTc0LDM3Mi45MTNWMTYwLjgxDQoJCQlsMTU5LjMwMyw3OS42NTFWNDUyLjU2NHogTTEwMS44MjYsMTI1LjY2MkwyNTYsNDguNTc2bDE1NC4xNzQsNzcuMDg3TDI1NiwyMDIuNzQ5TDEwMS44MjYsMTI1LjY2MnogTTQzNy4wMjYsMzcyLjkxMw0KCQkJbC0xNTkuMzAzLDc5LjY1MVYyNDAuNDYxbDE1OS4zMDMtNzkuNjUxVjM3Mi45MTN6IiBmaWxsPSIjRkZGIi8+DQoJPC9nPg0KPC9nPg0KPGc+DQo8L2c+DQo8Zz4NCjwvZz4NCjxnPg0KPC9nPg0KPGc+DQo8L2c+DQo8Zz4NCjwvZz4NCjxnPg0KPC9nPg0KPGc+DQo8L2c+DQo8Zz4NCjwvZz4NCjxnPg0KPC9nPg0KPGc+DQo8L2c+DQo8Zz4NCjwvZz4NCjxnPg0KPC9nPg0KPGc+DQo8L2c+DQo8Zz4NCjwvZz4NCjxnPg0KPC9nPg0KPC9zdmc+DQo=" height="22">][crates-url]
[<img alt="crates.io" src="https://img.shields.io/crates/d/mediatime?color=critical&logo=data:image/svg+xml;base64,PD94bWwgdmVyc2lvbj0iMS4wIiBzdGFuZGFsb25lPSJubyI/PjwhRE9DVFlQRSBzdmcgUFVCTElDICItLy9XM0MvL0RURCBTVkcgMS4xLy9FTiIgImh0dHA6Ly93d3cudzMub3JnL0dyYXBoaWNzL1NWRy8xLjEvRFREL3N2ZzExLmR0ZCI+PHN2ZyB0PSIxNjQ1MTE3MzMyOTU5IiBjbGFzcz0iaWNvbiIgdmlld0JveD0iMCAwIDEwMjQgMTAyNCIgdmVyc2lvbj0iMS4xIiB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHAtaWQ9IjM0MjEiIGRhdGEtc3BtLWFuY2hvci1pZD0iYTMxM3guNzc4MTA2OS4wLmkzIiB3aWR0aD0iNDgiIGhlaWdodD0iNDgiIHhtbG5zOnhsaW5rPSJodHRwOi8vd3d3LnczLm9yZy8xOTk5L3hsaW5rIj48ZGVmcz48c3R5bGUgdHlwZT0idGV4dC9jc3MiPjwvc3R5bGU+PC9kZWZzPjxwYXRoIGQ9Ik00NjkuMzEyIDU3MC4yNHYtMjU2aDg1LjM3NnYyNTZoMTI4TDUxMiA3NTYuMjg4IDM0MS4zMTIgNTcwLjI0aDEyOHpNMTAyNCA2NDAuMTI4QzEwMjQgNzgyLjkxMiA5MTkuODcyIDg5NiA3ODcuNjQ4IDg5NmgtNTEyQzEyMy45MDQgODk2IDAgNzYxLjYgMCA1OTcuNTA0IDAgNDUxLjk2OCA5NC42NTYgMzMxLjUyIDIyNi40MzIgMzAyLjk3NiAyODQuMTYgMTk1LjQ1NiAzOTEuODA4IDEyOCA1MTIgMTI4YzE1Mi4zMiAwIDI4Mi4xMTIgMTA4LjQxNiAzMjMuMzkyIDI2MS4xMkM5NDEuODg4IDQxMy40NCAxMDI0IDUxOS4wNCAxMDI0IDY0MC4xOTJ6IG0tMjU5LjItMjA1LjMxMmMtMjQuNDQ4LTEyOS4wMjQtMTI4Ljg5Ni0yMjIuNzItMjUyLjgtMjIyLjcyLTk3LjI4IDAtMTgzLjA0IDU3LjM0NC0yMjQuNjQgMTQ3LjQ1NmwtOS4yOCAyMC4yMjQtMjAuOTI4IDIuOTQ0Yy0xMDMuMzYgMTQuNC0xNzguMzY4IDEwNC4zMi0xNzguMzY4IDIxNC43MiAwIDExNy45NTIgODguODMyIDIxNC40IDE5Ni45MjggMjE0LjRoNTEyYzg4LjMyIDAgMTU3LjUwNC03NS4xMzYgMTU3LjUwNC0xNzEuNzEyIDAtODguMDY0LTY1LjkyLTE2NC45MjgtMTQ0Ljk2LTE3MS43NzZsLTI5LjUwNC0yLjU2LTUuODg4LTMwLjk3NnoiIGZpbGw9IiNmZmZmZmYiIHAtaWQ9IjM0MjIiIGRhdGEtc3BtLWFuY2hvci1pZD0iYTMxM3guNzc4MTA2OS4wLmkwIiBjbGFzcz0iIj48L3BhdGg+PC9zdmc+&style=for-the-badge" height="22">][crates-url]
<img alt="license" src="https://img.shields.io/badge/License-Apache%202.0/MIT-blue.svg?style=for-the-badge&fontColor=white&logoColor=f5c076&logo=data:image/svg+xml;base64,PCFET0NUWVBFIHN2ZyBQVUJMSUMgIi0vL1czQy8vRFREIFNWRyAxLjEvL0VOIiAiaHR0cDovL3d3dy53My5vcmcvR3JhcGhpY3MvU1ZHLzEuMS9EVEQvc3ZnMTEuZHRkIj4KDTwhLS0gVXBsb2FkZWQgdG86IFNWRyBSZXBvLCB3d3cuc3ZncmVwby5jb20sIFRyYW5zZm9ybWVkIGJ5OiBTVkcgUmVwbyBNaXhlciBUb29scyAtLT4KPHN2ZyBmaWxsPSIjZmZmZmZmIiBoZWlnaHQ9IjgwMHB4IiB3aWR0aD0iODAwcHgiIHZlcnNpb249IjEuMSIgaWQ9IkNhcGFfMSIgeG1sbnM9Imh0dHA6Ly93d3cudzMub3JnLzIwMDAvc3ZnIiB4bWxuczp4bGluaz0iaHR0cDovL3d3dy53My5vcmcvMTk5OS94bGluayIgdmlld0JveD0iMCAwIDI3Ni43MTUgMjc2LjcxNSIgeG1sOnNwYWNlPSJwcmVzZXJ2ZSIgc3Ryb2tlPSIjZmZmZmZmIj4KDTxnIGlkPSJTVkdSZXBvX2JnQ2FycmllciIgc3Ryb2tlLXdpZHRoPSIwIi8+Cg08ZyBpZD0iU1ZHUmVwb190cmFjZXJDYXJyaWVyIiBzdHJva2UtbGluZWNhcD0icm91bmQiIHN0cm9rZS1saW5lam9pbj0icm91bmQiLz4KDTxnIGlkPSJTVkdSZXBvX2ljb25DYXJyaWVyIj4gPGc+IDxwYXRoIGQ9Ik0xMzguMzU3LDBDNjIuMDY2LDAsMCw2Mi4wNjYsMCwxMzguMzU3czYyLjA2NiwxMzguMzU3LDEzOC4zNTcsMTM4LjM1N3MxMzguMzU3LTYyLjA2NiwxMzguMzU3LTEzOC4zNTcgUzIxNC42NDgsMCwxMzguMzU3LDB6IE0xMzguMzU3LDI1OC43MTVDNzEuOTkyLDI1OC43MTUsMTgsMjA0LjcyMywxOCwxMzguMzU3UzcxLjk5MiwxOCwxMzguMzU3LDE4IHMxMjAuMzU3LDUzLjk5MiwxMjAuMzU3LDEyMC4zNTdTMjA0LjcyMywyNTguNzE1LDEzOC4zNTcsMjU4LjcxNXoiLz4gPHBhdGggZD0iTTE5NC43OTgsMTYwLjkwM2MtNC4xODgtMi42NzctOS43NTMtMS40NTQtMTIuNDMyLDIuNzMyYy04LjY5NCwxMy41OTMtMjMuNTAzLDIxLjcwOC0zOS42MTQsMjEuNzA4IGMtMjUuOTA4LDAtNDYuOTg1LTIxLjA3OC00Ni45ODUtNDYuOTg2czIxLjA3Ny00Ni45ODYsNDYuOTg1LTQ2Ljk4NmMxNS42MzMsMCwzMC4yLDcuNzQ3LDM4Ljk2OCwyMC43MjMgYzIuNzgyLDQuMTE3LDguMzc1LDUuMjAxLDEyLjQ5NiwyLjQxOGM0LjExOC0yLjc4Miw1LjIwMS04LjM3NywyLjQxOC0xMi40OTZjLTEyLjExOC0xNy45MzctMzIuMjYyLTI4LjY0NS01My44ODItMjguNjQ1IGMtMzUuODMzLDAtNjQuOTg1LDI5LjE1Mi02NC45ODUsNjQuOTg2czI5LjE1Miw2NC45ODYsNjQuOTg1LDY0Ljk4NmMyMi4yODEsMCw0Mi43NTktMTEuMjE4LDU0Ljc3OC0zMC4wMDkgQzIwMC4yMDgsMTY5LjE0NywxOTguOTg1LDE2My41ODIsMTk0Ljc5OCwxNjAuOTAzeiIvPiA8L2c+IDwvZz4KDTwvc3ZnPg==" height="22">
</div>
## Overview
`mediatime` provides the same three primitives every media pipeline reinvents, done once with integer-exact semantics:
- **[`Timebase`]** — a rational `num/den` (both `i32`; the constructor requires `num >= 0` and `den > 0`). Signed to match FFmpeg's `AVRational`, which is a pair of C `int`s: a `u32` numerator or denominator above `i32::MAX` is representable but cannot round-trip into an `AVRational`, and `i32` is also a native `INTEGER` on PostgreSQL, MySQL and SQLite. Common values: `1/1000` (ms PTS), `1/90000` (MPEG-TS), `30000/1001` (NTSC frame rate).
- **[`Timestamp`]** — an `i64` PTS tagged with a `Timebase`. Two timestamps compare by the *instant* they represent, not by their raw `(pts, timebase)` tuple, so `Timestamp(1_000, 1/1000)` equals `Timestamp(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 single `Timebase`. Carries the endpoints as raw PTS; returns `Timestamp` on 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.
[`Timebase`]: https://docs.rs/mediatime/latest/mediatime/struct.Timebase.html
[`Timestamp`]: https://docs.rs/mediatime/latest/mediatime/struct.Timestamp.html
[`TimeRange`]: https://docs.rs/mediatime/latest/mediatime/struct.TimeRange.html
## 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:
```text
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-timebase `cmp` uses 128-bit cross-multiply — exact for any `i32` numerator/denominator with any `i64` PTS. Spans and ranges are compared as written instead, and carry no `Ord` at 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 `HashMap` keys.
- **FFmpeg-style utilities.** `checked_rescale` / `saturating_rescale` (a.k.a. `av_rescale_q`, rounding to nearest with halfway cases away from zero, as FFmpeg's `AV_ROUND_NEAR_INF` does), `checked_duration_to_pts` / `checked_pts_to_duration`, `duration_since`, `saturating_sub_duration`. Every lossy conversion is spelled `checked_` or `saturating_` — there is no bare name whose overflow posture you have to remember.
- **Rates are their own type.** `Rate` is a timebase read the other way round — events per second rather than seconds per tick — so a frame rate cannot reach `av_rescale_q` as 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 with `to_timebase` / `from_timebase`. Its eight rates are `Timebase`'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.** `SignedDuration` is what the difference of two instants actually is — `later.signed_duration_since(&earlier)` — and `Duration` cannot 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)` — because `2 @ 1/1` and `1000 @ 1/1000` are 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, and `FromStr` accepts either a name or `num/den`. One value, one name: the roster holds the values a convention travels with, so Matroska's and FLV's millisecond bases are both `MILLIS` with 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.
- **`TimeRange` interpolation.** Linear midpoint (`interpolate(t)`) for placing an event somewhere between fade-out and fade-in frames, with `t ∈ [0, 1]` clamped.
- **`Display` for 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`.
- **`FromStr` for 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_alloc` library.** The library builds without `std` and `alloc`; tests use `std`.
- **`const fn` throughout.** Build `Timebase` / `Timestamp` / `TimeRange` in `const` context.
## Example
```rust
use core::num::NonZeroI32;
use core::time::Duration;
use mediatime::{Rate, Timebase, Timestamp, TimeRange};
// FFmpeg-style rational timebases — spelled out, or taken from the roster.
let ms = Timebase::new(1, NonZeroI32::new(1000).unwrap());
let mpegts = Timebase::new(1, NonZeroI32::new(90_000).unwrap());
assert_eq!(ms, Timebase::MILLIS);
assert_eq!(Timebase::from_name("MPEG_90K"), Some(mpegts));
assert_eq!(mpegts.well_known_name(), Some("MPEG_90K"));
// Same instant in two different timebases — they compare equal.
let a = Timestamp::new(1_000, ms);
let b = Timestamp::new(90_000, mpegts);
assert_eq!(a, b);
assert_eq!(a.duration_since(&b), Some(Duration::ZERO));
// `av_rescale_q`-style conversion, rounding to the nearest tick.
assert_eq!(ms.checked_rescale(500, mpegts), Some(45_000));
assert_eq!(ms.saturating_rescale(500, mpegts), 45_000);
// 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(&Timestamp::new(45_000, mpegts));
assert_eq!(span.ticks(), 45_000); // half a second, on the MPEG clock
assert_eq!(a.checked_add_signed(span), Some(Timestamp::new(1_500, ms)));
// A frame rate is its own type: the reciprocal reading, and it knows how
// long n frames take.
let ntsc = Rate::fps(30_000, NonZeroI32::new(1001).unwrap());
assert_eq!(ntsc, Rate::FPS_29_97);
assert_eq!(ntsc.to_timebase(), Timebase::NTSC_VIDEO);
assert_eq!(
ntsc.checked_frames_to_duration(30_000),
Some(Duration::from_secs(1001))
);
// A half-open [start, end) range with interpolation.
let r = TimeRange::new(100, 500, ms);
assert_eq!(r.interpolate(0.5).pts(), 300);
assert_eq!(r.duration(), Duration::from_millis(400));
// `Display` renders for humans; `{:#}` renders the exact stored value.
assert_eq!(format!("{ms}"), "1/1000");
assert_eq!(format!("{}", Timestamp::new(12_345, mpegts)), "0:00:00.137");
assert_eq!(format!("{:#}", Timestamp::new(12_345, mpegts)), "12345 @ 1/90000");
assert_eq!(format!("{r}"), "[0:00:00.100, 0:00:00.500)");
assert_eq!(format!("{r:#}"), "[100, 500) @ 1/1000");
assert_eq!(format!("{ntsc}"), "30000/1001");
assert_eq!(format!("{span}"), "45000 @ 1/90000");
// `FromStr` inverts the exact form, and also reads a roster name.
assert_eq!("1/1000".parse::<Timebase>(), Ok(ms));
assert_eq!("MILLIS".parse::<Timebase>(), Ok(ms));
assert_eq!("[100, 500) @ 1/1000".parse::<TimeRange>(), Ok(r));
assert_eq!("FPS_29_97".parse::<Rate>(), Ok(ntsc));
assert_eq!("45000 @ 1/90000".parse(), Ok(span));
// Each roster stays on its own door: a rate is not a timebase.
assert!("MILLIS".parse::<Rate>().is_err());
assert!("FPS_29_97".parse::<Timebase>().is_err());
```
## Installation
```toml
[dependencies]
mediatime = "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-APACHE), [LICENSE-MIT](LICENSE-MIT) for details.
Copyright (c) 2026 FinDIT Studio authors.
[Github-url]: https://github.com/findit-studio/mediatime/
[CI-url]: https://github.com/findit-studio/mediatime/actions/workflows/ci.yml
[doc-url]: https://docs.rs/mediatime
[crates-url]: https://crates.io/crates/mediatime
[codecov-url]: https://app.codecov.io/gh/findit-studio/mediatime/