diff --git a/.codecov.yml b/.codecov.yml
new file mode 100644
index 0000000..81d9826
--- /dev/null
+++ b/.codecov.yml
@@ -0,0 +1,26 @@
+codecov:
+ require_ci_to_pass: false
+
+ignore:
+ - benches/*
+ - examples/*
+ - tests/*
+
+coverage:
+ status:
+ project: # Overall project status
+ default:
+ target: auto
+ if_not_found: success
+ only_pulls: false
+ patch: # Status for the patch in pull requests
+ default:
+ target: auto
+ if_not_found: success
+ only_pulls: true
+ changes: false # Whether to comment on the coverage changes in pull requests
+
+comment:
+ layout: "header, diff, files, footer"
+ behavior: default
+ require_changes: false
diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
index 36fb0fc..4e6ebfe 100644
--- a/.github/workflows/ci.yml
+++ b/.github/workflows/ci.yml
@@ -18,7 +18,8 @@ on:
- '**.md'
- '**.txt'
workflow_dispatch:
- schedule: [cron: "0 1 */7 * *"]
+ schedule:
+ - cron: "0 1 1 * *"
env:
CARGO_TERM_COLOR: always
@@ -55,7 +56,7 @@ jobs:
- name: Install cargo-hack
run: cargo install cargo-hack
- name: Apply clippy lints
- run: cargo hack clippy --each-feature --exclude-no-default-features
+ run: cargo hack clippy --each-feature
# Run tests on some extra platforms
cross:
@@ -125,7 +126,7 @@ jobs:
- name: Install cargo-hack
run: cargo install cargo-hack
- name: Run build
- run: cargo hack build --feature-powerset --exclude-no-default-features
+ run: cargo hack build --feature-powerset
test:
name: test
@@ -154,156 +155,7 @@ jobs:
- name: Install cargo-hack
run: cargo install cargo-hack
- name: Run test
- run: cargo hack test --feature-powerset --exclude-no-default-features --exclude-features loom
-
- sanitizer:
- name: sanitizer
- runs-on: ubuntu-latest
- steps:
- - uses: actions/checkout@v6
- - name: Cache cargo build and registry
- uses: actions/cache@v5
- with:
- path: |
- ~/.cargo/registry
- ~/.cargo/git
- target
- key: ${{ runner.os }}-sanitizer-${{ hashFiles('**/Cargo.lock') }}
- restore-keys: |
- ${{ runner.os }}-sanitizer-
- - name: Install Rust
- run: rustup update nightly && rustup default nightly
- - name: Install rust-src
- run: rustup component add rust-src
- - name: ASAN / LSAN / MSAN / TSAN
- run: bash ci/sanitizer.sh
-
- miri-tb:
- name: miri-tb-${{ matrix.target }}
- strategy:
- matrix:
- include:
- - os: ubuntu-latest
- target: x86_64-unknown-linux-gnu
- - os: ubuntu-latest
- target: aarch64-unknown-linux-gnu
- - os: ubuntu-latest
- target: i686-unknown-linux-gnu
- - os: ubuntu-latest
- target: powerpc64-unknown-linux-gnu
- - os: ubuntu-latest
- target: s390x-unknown-linux-gnu
- - os: ubuntu-latest
- target: riscv64gc-unknown-linux-gnu
- - os: macos-latest
- target: aarch64-apple-darwin
- runs-on: ${{ matrix.os }}
- steps:
- - uses: actions/checkout@v6
- - name: Cache cargo build and registry
- uses: actions/cache@v5
- with:
- path: |
- ~/.cargo/registry
- ~/.cargo/git
- target
- key: ${{ runner.os }}-miri-${{ hashFiles('**/Cargo.lock') }}
- restore-keys: |
- ${{ runner.os }}-miri-
- - name: Miri
- run: |
- bash ci/miri_tb.sh "${{ matrix.target }}"
-
- miri-sb:
- name: miri-sb-${{ matrix.target }}
- strategy:
- matrix:
- include:
- - os: ubuntu-latest
- target: x86_64-unknown-linux-gnu
- - os: ubuntu-latest
- target: aarch64-unknown-linux-gnu
- - os: ubuntu-latest
- target: i686-unknown-linux-gnu
- - os: ubuntu-latest
- target: powerpc64-unknown-linux-gnu
- - os: ubuntu-latest
- target: s390x-unknown-linux-gnu
- - os: ubuntu-latest
- target: riscv64gc-unknown-linux-gnu
- - os: macos-latest
- target: aarch64-apple-darwin
- runs-on: ${{ matrix.os }}
- steps:
- - uses: actions/checkout@v6
- - name: Cache cargo build and registry
- uses: actions/cache@v5
- with:
- path: |
- ~/.cargo/registry
- ~/.cargo/git
- target
- key: ${{ runner.os }}-miri-${{ hashFiles('**/Cargo.lock') }}
- restore-keys: |
- ${{ runner.os }}-miri-
- - name: Miri
- run: |
- bash ci/miri_sb.sh "${{ matrix.target }}"
-
- loom:
- name: loom
- strategy:
- matrix:
- os:
- - ubuntu-latest
- - macos-latest
- - windows-latest
- runs-on: ${{ matrix.os }}
- steps:
- - uses: actions/checkout@v6
- - name: Cache cargo build and registry
- uses: actions/cache@v5
- with:
- path: |
- ~/.cargo/registry
- ~/.cargo/git
- target
- key: ${{ runner.os }}-loom-${{ hashFiles('**/Cargo.lock') }}
- restore-keys: |
- ${{ runner.os }}-loom-
- - name: Install Rust
- run: rustup update nightly --no-self-update && rustup default nightly
- - name: Loom tests
- run: cargo test --tests --features loom
-
- # valgrind:
- # name: valgrind
- # runs-on: ubuntu-latest
- # steps:
- # - uses: actions/checkout@v6
- # - name: Cache cargo build and registry
- # uses: actions/cache@v5
- # with:
- # path: |
- # ~/.cargo/registry
- # ~/.cargo/git
- # target
- # key: ubuntu-latest-valgrind-${{ hashFiles('**/Cargo.lock') }}
- # restore-keys: |
- # ubuntu-latest-valgrind-
- # - name: Install Rust
- # run: rustup update stable && rustup default stable
- # - name: Install Valgrind
- # run: |
- # sudo apt-get update -y
- # sudo apt-get install -y valgrind
- # # Uncomment and customize when you have binaries to test:
- # # - name: cargo build foo
- # # run: cargo build --bin foo
- # # working-directory: integration
- # # - name: Run valgrind foo
- # # run: valgrind --error-exitcode=1 --leak-check=full --show-leak-kinds=all ./target/debug/foo
- # # working-directory: integration
+ run: cargo hack test --feature-powerset
coverage:
name: coverage
@@ -314,8 +166,6 @@ jobs:
- build
- cross
- test
- - sanitizer
- - loom
steps:
- uses: actions/checkout@v6
- name: Install Rust
@@ -337,7 +187,7 @@ jobs:
RUSTFLAGS: "--cfg tarpaulin"
run: cargo tarpaulin --all-features --run-types tests --run-types doctests --workspace --out xml
- name: Upload to codecov.io
- uses: codecov/codecov-action@v5
+ uses: codecov/codecov-action@v6
with:
token: ${{ secrets.CODECOV_TOKEN }}
slug: ${{ github.repository }}
diff --git a/.github/workflows/loc.yml b/.github/workflows/loc.yml
index 9d629a5..86cbe2c 100644
--- a/.github/workflows/loc.yml
+++ b/.github/workflows/loc.yml
@@ -41,7 +41,7 @@ jobs:
run: |
tokeit --lang rust
- name: Upload total loc to GitHub Gist
- uses: actions/github-script@v8
+ uses: actions/github-script@v9
with:
github-token: ${{ secrets.GIST_PAT }}
script: |
@@ -51,7 +51,7 @@ jobs:
await github.rest.gists.update({
gist_id: gistId,
files: {
- "template-rs": {
+ "mediatime": {
content: output
}
}
diff --git a/CHANGELOG.md b/CHANGELOG.md
index bd7a668..712c46d 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -1,7 +1,56 @@
-# UNRELEASED
+# Changelog
-# 0.1.2 (January 6th, 2022)
+All notable changes to this crate are documented here. The format follows
+[Keep a Changelog](https://keepachangelog.com/en/1.1.0/) and this project
+adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
-FEATURES
+## [Unreleased]
+## [0.1.0] — April 17, 2026
+Initial public release. First-cut API — expect minor refinements before 1.0.
+
+### Added
+
+- `Timebase` — rational `num/den` (`u32` numerator, `NonZeroU32` denominator).
+ Mirrors FFmpeg's `AVRational`. Supports value-based equality, ordering, and
+ hashing (reduced-form rational), so `1/2 == 2/4 == 3/6` and all three hash
+ identically.
+- `Timestamp` — integer PTS (`i64`) tagged with a `Timebase`. Semantic
+ comparison across different timebases via 128-bit cross-multiplication —
+ no rounding, no division.
+- `TimeRange` — half-open `[start, end)` interval sharing a `Timebase`, with
+ `start()` / `end()` as `Timestamp`, `duration()`, and clamped linear
+ `interpolate(t)` for midpoint / bias placement.
+- Timebase utilities: `rescale_pts` (FFmpeg's `av_rescale_q`), `rescale`,
+ `frames_to_duration`, `duration_to_pts`, `num`/`den` accessors, `with_*`
+ consuming builders and `set_*` in-place setters.
+- Timestamp utilities: `pts`/`timebase` accessors, `with_pts`/`set_pts`,
+ `rescale_to`, `saturating_sub_duration`, `duration_since`, `cmp_semantic`
+ (const-fn form of `Ord::cmp`).
+- TimeRange utilities: `new`, `instant`, `start_pts`/`end_pts`/`timebase`
+ accessors, `with_*`/`set_*` setters for both endpoints, `is_instant`.
+- `const fn` across the whole public surface — every constructor, accessor,
+ and setter can be evaluated in a `const` context.
+- `#![no_std]` always, zero dependencies. No allocation anywhere — every
+ public type is `Copy`.
+
+### Behavior
+
+- All comparisons between types are **semantic**, not structural: two
+ `Timestamp`s representing the same instant in different timebases are
+ `Eq`, `Ord::Equal`, and hash the same. Use this directly as a `HashMap` or
+ `BTreeMap` key without worrying about canonicalization.
+- Cross-timebase arithmetic (rescaling, `duration_since`) uses 128-bit
+ intermediates throughout — exact for any `u32`×`u32` timebase combined
+ with any `i64` PTS in the real-video range.
+- `rescale_pts` rounds toward zero, matching `av_rescale_q` with default
+ rounding. Saturating variants are not provided yet — overflow in
+ `duration_to_pts` is clamped to `i64::MAX`.
+
+### Testing
+
+- 100% line coverage on `src/lib.rs` under
+ `cargo tarpaulin --all-features --run-types tests --run-types doctests`.
+- Criterion bench (`cargo bench --bench gcd`) for the internal GCD helpers
+ used by `Hash`; ships both Euclidean and binary variants for comparison.
diff --git a/Cargo.toml b/Cargo.toml
index ff7fe91..1157fae 100644
--- a/Cargo.toml
+++ b/Cargo.toml
@@ -1,23 +1,23 @@
[package]
-name = "template-rs"
-version = "0.0.0"
-edition = "2021"
-repository = "https://github.com/al8n/template-rs"
-homepage = "https://github.com/al8n/template-rs"
-documentation = "https://docs.rs/template-rs"
-description = "A template for creating Rust open-source repo on GitHub"
+name = "mediatime"
+version = "0.1.0"
+edition = "2024"
+repository = "https://github.com/findit-ai/mediatime"
+homepage = "https://github.com/findit-ai/mediatime"
+documentation = "https://docs.rs/mediatime"
+description = "Exact-integer rational time types for media pipelines — FFmpeg-style Timebase, Timestamp, and TimeRange. no_std, zero dependencies, const fn."
license = "MIT OR Apache-2.0"
-rust-version = "1.73"
+rust-version = "1.85"
+keywords = ["media", "timebase", "timestamp", "pts", "ffmpeg"]
+categories = ["multimedia", "multimedia::video", "date-and-time", "no-std", "no-std::no-alloc"]
[[bench]]
-path = "benches/foo.rs"
-name = "foo"
+path = "benches/gcd.rs"
+name = "gcd"
harness = false
[features]
-default = ["std"]
-alloc = []
-std = []
+default = []
[dependencies]
diff --git a/README-zh_CN.md b/README-zh_CN.md
deleted file mode 100644
index 7a07f4d..0000000
--- a/README-zh_CN.md
+++ /dev/null
@@ -1,51 +0,0 @@
-
-
template-rs
-
-
-
-开源Rust代码库GitHub模版
-
-[

][Github-url]
-

-[

][CI-url]
-[

][codecov-url]
-
-[

][doc-url]
-[

][crates-url]
-[

][crates-url]
-

-
-[English][en-url] | 简体中文
-
-
-
-## Installation
-
-```toml
-[dependencies]
-template_rs = "0.1"
-```
-
-## Features
-
-- [x] 更快的创建GitHub开源Rust代码库
-
-#### License
-
-`Template-rs` 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) 2021 Al Liu.
-
-[Github-url]: https://github.com/al8n/template-rs/
-[CI-url]: https://github.com/al8n/template/actions/workflows/template.yml
-[doc-url]: https://docs.rs/template-rs
-[crates-url]: https://crates.io/crates/template-rs
-[codecov-url]: https://app.codecov.io/gh/al8n/template-rs/
-[license-url]: https://opensource.org/licenses/Apache-2.0
-[rustc-url]: https://github.com/rust-lang/rust/blob/master/RELEASES.md
-[license-apache-url]: https://opensource.org/licenses/Apache-2.0
-[license-mit-url]: https://opensource.org/licenses/MIT
-[en-url]: https://github.com/al8n/template-rs/tree/main/README.md
diff --git a/README.md b/README.md
index 1af27e2..5a28645 100644
--- a/README.md
+++ b/README.md
@@ -1,46 +1,108 @@
-
template-rs
+mediatime
-A template for creating Rust open-source GitHub repo.
+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.
-[

][Github-url]
-

-[

][CI-url]
-[

][codecov-url]
+[

][Github-url]
+

+[

][CI-url]
+[

][codecov-url]
-[

][doc-url]
-[

][crates-url]
-[

][crates-url]
+[

][doc-url]
+[

][crates-url]
+[

][crates-url]

-English | [简体中文][zh-cn-url]
-
+## Overview
+
+`mediatime` provides the same three primitives every media pipeline reinvents, done once with integer-exact semantics:
+
+- **[`Timebase`]** — a rational `num/den` (both `u32`, non-zero denominator). Directly mirrors FFmpeg's `AVRational`. 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.** `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 `u32` numerator/denominator with any `i64` PTS.
+- **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.** `rescale_pts` (a.k.a. `av_rescale_q`), `frames_to_duration`, `duration_to_pts`, `duration_since`, `saturating_sub_duration`.
+- **`TimeRange` interpolation.** Linear midpoint (`interpolate(t)`) for placing an event somewhere between fade-out and fade-in frames, with `t ∈ [0, 1]` clamped.
+- **`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::NonZeroU32;
+use core::time::Duration;
+use mediatime::{Timebase, Timestamp, TimeRange};
+
+// FFmpeg-style rational timebases.
+let ms = Timebase::new(1, NonZeroU32::new(1000).unwrap());
+let mpegts = Timebase::new(1, NonZeroU32::new(90_000).unwrap());
+
+// 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 toward zero.
+assert_eq!(ms.rescale(500, mpegts), 45_000);
+
+// Frame rate helpers — treat `Timebase` as fps and count frames.
+let ntsc = Timebase::new(30_000, NonZeroU32::new(1001).unwrap());
+assert_eq!(ntsc.frames_to_duration(30_000), 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(), Some(Duration::from_millis(400)));
+```
+
## Installation
```toml
[dependencies]
-template_rs = "0.1"
+mediatime = "0.1"
```
-## Features
-- [x] Create a Rust open-source repo fast
+## MSRV
+
+Rust 1.85.
#### License
-`template-rs` is under the terms of both the MIT license and the
+`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) 2021 Al Liu.
+Copyright (c) 2026 FinDIT Studio authors.
-[Github-url]: https://github.com/al8n/template-rs/
-[CI-url]: https://github.com/al8n/template-rs/actions/workflows/ci.yml
-[doc-url]: https://docs.rs/template-rs
-[crates-url]: https://crates.io/crates/template-rs
-[codecov-url]: https://app.codecov.io/gh/al8n/template-rs/
-[zh-cn-url]: https://github.com/al8n/template-rs/tree/main/README-zh_CN.md
+[Github-url]: https://github.com/findit-ai/mediatime/
+[CI-url]: https://github.com/findit-ai/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-ai/mediatime/
diff --git a/benches/gcd.rs b/benches/gcd.rs
new file mode 100644
index 0000000..94ccb62
--- /dev/null
+++ b/benches/gcd.rs
@@ -0,0 +1,168 @@
+//! Criterion benchmark comparing GCD strategies for the two private helpers
+//! (`gcd_u32`, `gcd_u128`) used by `Timebase::hash` and `Timestamp::hash`.
+//! The Euclidean variants are copied inline from `src/lib.rs` (keep them
+//! bit-identical) so the bench doesn't need to expose the private helpers.
+//! Two additional binary-GCD (Stein's algorithm) variants are defined here
+//! only — they live in the bench rather than the library because the
+//! Euclidean versions benchmark faster on every realistic input we measured.
+//!
+//! Run with `cargo bench --bench gcd`.
+
+use std::hint::black_box;
+
+use criterion::{BenchmarkId, Criterion, criterion_group, criterion_main};
+
+// ---------------------------------------------------------------------------
+// Copies of the two Euclidean GCD helpers from src/lib.rs. Keep in sync.
+// ---------------------------------------------------------------------------
+
+#[inline(always)]
+fn gcd_u32(mut a: u32, mut b: u32) -> u32 {
+ while b != 0 {
+ let t = b;
+ b = a % b;
+ a = t;
+ }
+ a
+}
+
+#[inline(always)]
+fn gcd_u128(mut a: u128, mut b: u128) -> u128 {
+ while b != 0 {
+ let t = b;
+ b = a % b;
+ a = t;
+ }
+ a
+}
+
+#[inline(always)]
+fn binary_gcd_u128(mut a: u128, mut b: u128) -> u128 {
+ if a == 0 {
+ return b;
+ }
+ if b == 0 {
+ return a;
+ }
+ let shift = (a | b).trailing_zeros();
+ a >>= a.trailing_zeros();
+ loop {
+ b >>= b.trailing_zeros();
+ if a > b {
+ core::mem::swap(&mut a, &mut b);
+ }
+ b -= a;
+ if b == 0 {
+ return a << shift;
+ }
+ }
+}
+
+// Also benchmark a u32 binary-GCD so the reader can judge whether it's
+// worth introducing for Timebase::hash too.
+#[inline(always)]
+fn binary_gcd_u32(mut a: u32, mut b: u32) -> u32 {
+ if a == 0 {
+ return b;
+ }
+ if b == 0 {
+ return a;
+ }
+ let shift = (a | b).trailing_zeros();
+ a >>= a.trailing_zeros();
+ loop {
+ b >>= b.trailing_zeros();
+ if a > b {
+ core::mem::swap(&mut a, &mut b);
+ }
+ b -= a;
+ if b == 0 {
+ return a << shift;
+ }
+ }
+}
+
+// ---------------------------------------------------------------------------
+// Workloads
+// ---------------------------------------------------------------------------
+
+/// Realistic `Timebase::hash` inputs — `(num, den)` for the common media
+/// timebases and frame rates.
+fn timebase_u32_inputs() -> &'static [(&'static str, u32, u32)] {
+ &[
+ ("ms_1/1000", 1, 1000),
+ ("ntsc_30000/1001", 30_000, 1001),
+ ("mpegts_1/90000", 1, 90_000),
+ ("audio_1/48000", 1, 48_000),
+ ("fps30_30/1", 30, 1),
+ ("coprime_large", 999_983, 999_979), // two large primes
+ ]
+}
+
+/// Realistic `Timestamp::hash` inputs — `(|pts| * num, den)` as u128, mixing
+/// small/large PTS values with typical denominators.
+fn timestamp_u128_inputs() -> &'static [(&'static str, u128, u128)] {
+ &[
+ // 1-second @ 1/1000 → numerator = 1000 * 1 = 1000, den = 1000
+ ("1s_ms", 1_000, 1_000),
+ // 1-hour @ 1/90000 → num = 90000 * 3600, den = 90000
+ ("1h_mpegts", 90_000u128 * 3600, 90_000),
+ // NTSC: 30 frames ticks → pts=30, tb=30000/1001 → n=30*30000, d=1001
+ ("30fr_ntsc", 30u128 * 30_000, 1001),
+ // Large PTS: 10⁹ in MPEG-TS → n = 1_000_000_000 * 1, d = 90_000
+ ("big_pts", 1_000_000_000, 90_000),
+ // Adversarial: two coprime 64-bit primes packed into u128
+ (
+ "coprime_64b",
+ 18_446_744_073_709_551_557u128,
+ 18_446_744_073_709_551_533u128,
+ ),
+ // Adversarial: two coprime 96-ish-bit values
+ (
+ "coprime_96b",
+ ((1u128 << 95) | 0x5151_5151_5151_5151_5151_5151u128) | 1,
+ ((1u128 << 95) | 0xa5a5_a5a5_a5a5_a5a5_a5a5_a5a5u128) | 3,
+ ),
+ ]
+}
+
+// ---------------------------------------------------------------------------
+// Benches
+// ---------------------------------------------------------------------------
+
+fn bench_u32(c: &mut Criterion) {
+ let mut group = c.benchmark_group("gcd_u32");
+ for &(label, a, b) in timebase_u32_inputs() {
+ group.bench_with_input(
+ BenchmarkId::new("euclidean", label),
+ &(a, b),
+ |bn, &(a, b)| {
+ bn.iter(|| gcd_u32(black_box(a), black_box(b)));
+ },
+ );
+ group.bench_with_input(BenchmarkId::new("binary", label), &(a, b), |bn, &(a, b)| {
+ bn.iter(|| binary_gcd_u32(black_box(a), black_box(b)));
+ });
+ }
+ group.finish();
+}
+
+fn bench_u128(c: &mut Criterion) {
+ let mut group = c.benchmark_group("gcd_u128");
+ for &(label, a, b) in timestamp_u128_inputs() {
+ group.bench_with_input(
+ BenchmarkId::new("euclidean", label),
+ &(a, b),
+ |bn, &(a, b)| {
+ bn.iter(|| gcd_u128(black_box(a), black_box(b)));
+ },
+ );
+ group.bench_with_input(BenchmarkId::new("binary", label), &(a, b), |bn, &(a, b)| {
+ bn.iter(|| binary_gcd_u128(black_box(a), black_box(b)));
+ });
+ }
+ group.finish();
+}
+
+criterion_group!(benches, bench_u32, bench_u128);
+criterion_main!(benches);
diff --git a/ci/miri_sb.sh b/ci/miri_sb.sh
deleted file mode 100755
index cc3c6e0..0000000
--- a/ci/miri_sb.sh
+++ /dev/null
@@ -1,38 +0,0 @@
-#!/bin/bash
-set -e
-
-if [ -z "$1" ]; then
- echo "Error: TARGET is not provided"
- exit 1
-fi
-
-TARGET="$1"
-
-# Install cross-compilation toolchain on Linux
-if [ "$(uname)" = "Linux" ]; then
- case "$TARGET" in
- aarch64-unknown-linux-gnu)
- sudo apt-get update && sudo apt-get install -y gcc-aarch64-linux-gnu
- ;;
- i686-unknown-linux-gnu)
- sudo apt-get update && sudo apt-get install -y gcc-multilib
- ;;
- powerpc64-unknown-linux-gnu)
- sudo apt-get update && sudo apt-get install -y gcc-powerpc64-linux-gnu
- ;;
- s390x-unknown-linux-gnu)
- sudo apt-get update && sudo apt-get install -y gcc-s390x-linux-gnu
- ;;
- riscv64gc-unknown-linux-gnu)
- sudo apt-get update && sudo apt-get install -y gcc-riscv64-linux-gnu
- ;;
- esac
-fi
-
-rustup toolchain install nightly --component miri
-rustup override set nightly
-cargo miri setup
-
-export MIRIFLAGS="-Zmiri-strict-provenance -Zmiri-disable-isolation -Zmiri-symbolic-alignment-check"
-
-cargo miri test --all-targets --target "$TARGET"
diff --git a/ci/miri_tb.sh b/ci/miri_tb.sh
deleted file mode 100755
index 5d374c7..0000000
--- a/ci/miri_tb.sh
+++ /dev/null
@@ -1,38 +0,0 @@
-#!/bin/bash
-set -e
-
-if [ -z "$1" ]; then
- echo "Error: TARGET is not provided"
- exit 1
-fi
-
-TARGET="$1"
-
-# Install cross-compilation toolchain on Linux
-if [ "$(uname)" = "Linux" ]; then
- case "$TARGET" in
- aarch64-unknown-linux-gnu)
- sudo apt-get update && sudo apt-get install -y gcc-aarch64-linux-gnu
- ;;
- i686-unknown-linux-gnu)
- sudo apt-get update && sudo apt-get install -y gcc-multilib
- ;;
- powerpc64-unknown-linux-gnu)
- sudo apt-get update && sudo apt-get install -y gcc-powerpc64-linux-gnu
- ;;
- s390x-unknown-linux-gnu)
- sudo apt-get update && sudo apt-get install -y gcc-s390x-linux-gnu
- ;;
- riscv64gc-unknown-linux-gnu)
- sudo apt-get update && sudo apt-get install -y gcc-riscv64-linux-gnu
- ;;
- esac
-fi
-
-rustup toolchain install nightly --component miri
-rustup override set nightly
-cargo miri setup
-
-export MIRIFLAGS="-Zmiri-strict-provenance -Zmiri-disable-isolation -Zmiri-symbolic-alignment-check -Zmiri-tree-borrows"
-
-cargo miri test --all-targets --target "$TARGET"
diff --git a/ci/sanitizer.sh b/ci/sanitizer.sh
deleted file mode 100755
index 4ff6819..0000000
--- a/ci/sanitizer.sh
+++ /dev/null
@@ -1,22 +0,0 @@
-#!/bin/bash
-set -ex
-
-export ASAN_OPTIONS="detect_odr_violation=0 detect_leaks=0"
-
-TARGET="x86_64-unknown-linux-gnu"
-
-# Run address sanitizer
-RUSTFLAGS="-Z sanitizer=address" \
-cargo test --tests --target "$TARGET" --all-features
-
-# Run leak sanitizer
-RUSTFLAGS="-Z sanitizer=leak" \
-cargo test --tests --target "$TARGET" --all-features
-
-# Run memory sanitizer (requires -Zbuild-std for instrumented std)
-RUSTFLAGS="-Z sanitizer=memory" \
-cargo -Zbuild-std test --tests --target "$TARGET" --all-features
-
-# Run thread sanitizer (requires -Zbuild-std for instrumented std)
-RUSTFLAGS="-Z sanitizer=thread" \
-cargo -Zbuild-std test --tests --target "$TARGET" --all-features
diff --git a/examples/foo.rs b/examples/foo.rs
deleted file mode 100644
index f328e4d..0000000
--- a/examples/foo.rs
+++ /dev/null
@@ -1 +0,0 @@
-fn main() {}
diff --git a/rustfmt.toml b/rustfmt.toml
index f54d5e6..b8516fb 100644
--- a/rustfmt.toml
+++ b/rustfmt.toml
@@ -3,6 +3,7 @@ hard_tabs = false
tab_spaces = 2
newline_style = "Auto"
use_small_heuristics = "Default"
+imports_granularity = "Crate"
reorder_imports = true
reorder_modules = true
remove_nested_parens = true
@@ -10,4 +11,3 @@ merge_derives = true
use_try_shorthand = true
use_field_init_shorthand = true
force_explicit_abi = true
-
diff --git a/src/lib.rs b/src/lib.rs
index 0a58390..2ca5098 100644
--- a/src/lib.rs
+++ b/src/lib.rs
@@ -1,11 +1,896 @@
-//! A template for creating Rust open-source repo on GitHub
-#![cfg_attr(not(feature = "std"), no_std)]
+#![doc = include_str!("../README.md")]
+#![cfg_attr(not(test), no_std)]
#![cfg_attr(docsrs, feature(doc_cfg))]
#![cfg_attr(docsrs, allow(unused_attributes))]
#![deny(missing_docs)]
+#![forbid(unsafe_code)]
-#[cfg(all(not(feature = "std"), feature = "alloc"))]
-extern crate alloc as std;
+use core::{
+ cmp::Ordering,
+ hash::{Hash, Hasher},
+ num::NonZeroU32,
+ time::Duration,
+};
-#[cfg(feature = "std")]
-extern crate std;
+/// A media timebase represented as a rational number: numerator over non-zero denominator.
+///
+/// Typical values: `1/1000` for millisecond PTS, `1/90000` for MPEG-TS,
+/// `1/48000` for audio samples, `30000/1001` for NTSC video (when used as a
+/// frame rate).
+///
+/// # Equality and ordering
+///
+/// Comparison is **value-based**: `1/2` equals `2/4`, and `1/3 < 2/3 < 1/1`.
+/// [`Hash`] hashes the reduced (lowest-terms) form, so equal rationals hash
+/// the same. Cross-multiplication uses `u64` intermediates — exact for any
+/// `u32` numerator / denominator.
+#[derive(Debug, Clone, Copy, Eq)]
+pub struct Timebase {
+ num: u32,
+ den: NonZeroU32,
+}
+
+impl Timebase {
+ /// Creates a new `Timebase` with the given numerator and non-zero denominator.
+ #[cfg_attr(not(tarpaulin), inline(always))]
+ pub const fn new(num: u32, den: NonZeroU32) -> Self {
+ Self { num, den }
+ }
+
+ /// Returns the numerator.
+ #[cfg_attr(not(tarpaulin), inline(always))]
+ pub const fn num(&self) -> u32 {
+ self.num
+ }
+
+ /// Returns the denominator.
+ #[cfg_attr(not(tarpaulin), inline(always))]
+ pub const fn den(&self) -> NonZeroU32 {
+ self.den
+ }
+
+ /// Set the value of the numerator.
+ #[cfg_attr(not(tarpaulin), inline(always))]
+ pub const fn with_num(mut self, num: u32) -> Self {
+ self.set_num(num);
+ self
+ }
+
+ /// Set the value of the denominator.
+ #[cfg_attr(not(tarpaulin), inline(always))]
+ pub const fn with_den(mut self, den: NonZeroU32) -> Self {
+ self.set_den(den);
+ self
+ }
+
+ /// Set the value of the numerator in place.
+ #[cfg_attr(not(tarpaulin), inline(always))]
+ pub const fn set_num(&mut self, num: u32) -> &mut Self {
+ self.num = num;
+ self
+ }
+
+ /// Set the value of the denominator in place.
+ #[cfg_attr(not(tarpaulin), inline(always))]
+ pub const fn set_den(&mut self, den: NonZeroU32) -> &mut Self {
+ self.den = den;
+ self
+ }
+
+ /// Rescales `pts` from timebase `from` to timebase `to`, rounding toward zero.
+ ///
+ /// Equivalent to FFmpeg's `av_rescale_q`. Uses a 128-bit intermediate to
+ /// avoid overflow for typical video PTS ranges. If the rescaled value
+ /// exceeds `i64`'s range (pathological for real video), the result is
+ /// **saturated** to `i64::MIN` or `i64::MAX` — this matches the behavior
+ /// promised by `duration_to_pts` and avoids silent wraparound.
+ ///
+ /// # Panics
+ ///
+ /// Panics if `to.num() == 0` (division by zero).
+ #[cfg_attr(not(tarpaulin), inline(always))]
+ pub const fn rescale_pts(pts: i64, from: Self, to: Self) -> i64 {
+ assert!(to.num != 0, "target timebase numerator must be non-zero");
+ // pts * (from.num / from.den) / (to.num / to.den)
+ // = pts * from.num * to.den / (from.den * to.num)
+ let numerator = (pts as i128) * (from.num as i128) * (to.den.get() as i128);
+ let denominator = (from.den.get() as i128) * (to.num as i128);
+ let q = numerator / denominator;
+ if q > i64::MAX as i128 {
+ i64::MAX
+ } else if q < i64::MIN as i128 {
+ i64::MIN
+ } else {
+ q as i64
+ }
+ }
+
+ /// Rescales `pts` from this timebase to `to`, rounding toward zero.
+ ///
+ /// Method form of [`Self::rescale_pts`]: `self` is the source timebase.
+ ///
+ /// # Panics
+ ///
+ /// Panics if `to.num() == 0` (division by zero).
+ #[cfg_attr(not(tarpaulin), inline(always))]
+ pub const fn rescale(&self, pts: i64, to: Self) -> i64 {
+ Self::rescale_pts(pts, *self, to)
+ }
+
+ /// Treats `self` as a frame rate (frames per second) and returns the
+ /// [`Duration`] corresponding to `frames` frames.
+ ///
+ /// Examples:
+ /// - 30 fps: `Timebase::new(30, nz(1)).frames_to_duration(15)` → 500 ms
+ /// - NTSC: `Timebase::new(30000, nz(1001)).frames_to_duration(30000)` → 1001 ms
+ ///
+ /// Note that "frame rate" and "PTS timebase" are conceptually *different*
+ /// rationals even though both are represented as [`Timebase`]. A 30 fps
+ /// stream typically has PTS timebase `1/30` (seconds per unit) and frame
+ /// rate `30/1` (frames per second) — they are reciprocals.
+ ///
+ /// # Panics
+ ///
+ /// Panics if `self.num() == 0` (division by zero).
+ #[cfg_attr(not(tarpaulin), inline(always))]
+ pub const fn frames_to_duration(&self, frames: u32) -> Duration {
+ // frames / (num/den) seconds = frames * den / num seconds
+ let num = self.num as u128;
+ let den = self.den.get() as u128;
+ assert!(num != 0, "frame rate numerator must be non-zero");
+ let total_ns = (frames as u128) * den * 1_000_000_000 / num;
+ let secs = (total_ns / 1_000_000_000) as u64;
+ let nanos = (total_ns % 1_000_000_000) as u32;
+ Duration::new(secs, nanos)
+ }
+
+ /// Converts a [`Duration`] into the number of PTS units this timebase
+ /// represents, rounding toward zero.
+ ///
+ /// Inverse of "multiplying a PTS value by this timebase to get seconds".
+ /// Saturates at `i64::MAX` if the duration is absurdly large for this
+ /// timebase. Returns `0` if `self.num() == 0` (a degenerate timebase).
+ #[cfg_attr(not(tarpaulin), inline(always))]
+ pub const fn duration_to_pts(&self, d: Duration) -> i64 {
+ let num = self.num as u128;
+ if num == 0 {
+ return 0;
+ }
+ let den = self.den.get() as u128;
+ // pts_units = duration_ns * den / (num * 1e9)
+ let ns = d.as_nanos();
+ let pts = ns * den / (num * 1_000_000_000);
+ if pts > i64::MAX as u128 {
+ i64::MAX
+ } else {
+ pts as i64
+ }
+ }
+}
+
+impl PartialEq for Timebase {
+ #[cfg_attr(not(tarpaulin), inline(always))]
+ fn eq(&self, other: &Self) -> bool {
+ // a.num * b.den == b.num * a.den (cross-multiply; u32 * u32 fits in u64)
+ (self.num as u64) * (other.den.get() as u64) == (other.num as u64) * (self.den.get() as u64)
+ }
+}
+
+impl Hash for Timebase {
+ fn hash(&self, state: &mut H) {
+ let d = self.den.get();
+ // gcd(num, d) ≥ 1 because d ≥ 1 (NonZeroU32).
+ let g = gcd_u32(self.num, d);
+ (self.num / g).hash(state);
+ (d / g).hash(state);
+ }
+}
+
+impl Ord for Timebase {
+ #[cfg_attr(not(tarpaulin), inline(always))]
+ fn cmp(&self, other: &Self) -> Ordering {
+ let lhs = (self.num as u64) * (other.den.get() as u64);
+ let rhs = (other.num as u64) * (self.den.get() as u64);
+ lhs.cmp(&rhs)
+ }
+}
+
+impl PartialOrd for Timebase {
+ #[cfg_attr(not(tarpaulin), inline(always))]
+ fn partial_cmp(&self, other: &Self) -> Option {
+ Some(self.cmp(other))
+ }
+}
+
+/// A presentation timestamp, expressed as a PTS value in units of an associated [`Timebase`].
+///
+/// # Equality and ordering
+///
+/// Comparison is **value-based** (same instant compares equal even across
+/// different timebases): `Timestamp(1000, 1/1000)` equals
+/// `Timestamp(90_000, 1/90_000)`. [`Hash`] hashes the reduced-form rational
+/// instant `(pts · num, den)`, so equal timestamps hash the same.
+///
+/// Cross-timebase comparisons use 128-bit cross-multiplication — no division,
+/// no rounding error. Same-timebase comparisons take a fast path on `pts`.
+#[derive(Debug, Clone, Copy)]
+pub struct Timestamp {
+ pts: i64,
+ timebase: Timebase,
+}
+
+impl Timestamp {
+ /// Creates a new `Timestamp` with the given PTS and timebase.
+ #[cfg_attr(not(tarpaulin), inline(always))]
+ pub const fn new(pts: i64, timebase: Timebase) -> Self {
+ Self { pts, timebase }
+ }
+
+ /// Returns the presentation timestamp, in units of [`Self::timebase`].
+ ///
+ /// To obtain a [`Duration`], use [`Self::duration_since`] against a reference
+ /// timestamp, or rescale via [`Self::rescale_to`].
+ #[cfg_attr(not(tarpaulin), inline(always))]
+ pub const fn pts(&self) -> i64 {
+ self.pts
+ }
+
+ /// Returns the timebase of the timestamp.
+ #[cfg_attr(not(tarpaulin), inline(always))]
+ pub const fn timebase(&self) -> Timebase {
+ self.timebase
+ }
+
+ /// Set the value of the presentation timestamp.
+ #[cfg_attr(not(tarpaulin), inline(always))]
+ pub const fn with_pts(mut self, pts: i64) -> Self {
+ self.set_pts(pts);
+ self
+ }
+
+ /// Set the value of the presentation timestamp in place.
+ #[cfg_attr(not(tarpaulin), inline(always))]
+ pub const fn set_pts(&mut self, pts: i64) -> &mut Self {
+ self.pts = pts;
+ self
+ }
+
+ /// Returns a new `Timestamp` representing the same instant in a different timebase.
+ ///
+ /// Rounds toward zero via [`Timebase::rescale_pts`]; round-tripping through a
+ /// coarser timebase can lose precision.
+ #[cfg_attr(not(tarpaulin), inline(always))]
+ pub const fn rescale_to(self, target: Timebase) -> Self {
+ Self {
+ pts: self.timebase.rescale(self.pts, target),
+ timebase: target,
+ }
+ }
+
+ /// Returns a new [`Timestamp`] representing this instant shifted backward
+ /// by `d`, in the same timebase. Saturates at `i64::MIN` if the subtraction
+ /// would underflow (pathological for real video).
+ ///
+ /// Useful for "virtual past" seeding: e.g., initializing a warmup-filter
+ /// state to `ts - min_duration` so the first detected cut can fire
+ /// immediately.
+ #[cfg_attr(not(tarpaulin), inline(always))]
+ pub const fn saturating_sub_duration(self, d: Duration) -> Self {
+ let units = self.timebase.duration_to_pts(d);
+ Self::new(self.pts.saturating_sub(units), self.timebase)
+ }
+
+ /// `const fn` form of [`Ord::cmp`]. Compares two timestamps by the instant
+ /// they represent, rescaling if timebases differ.
+ ///
+ /// Uses a 128-bit cross-multiply for the mixed-timebase case; no division,
+ /// so no rounding error. Same-timebase comparisons take a direct fast path.
+ #[cfg_attr(not(tarpaulin), inline(always))]
+ pub const fn cmp_semantic(&self, other: &Self) -> Ordering {
+ if self.timebase.num == other.timebase.num
+ && self.timebase.den.get() == other.timebase.den.get()
+ {
+ return if self.pts < other.pts {
+ Ordering::Less
+ } else if self.pts > other.pts {
+ Ordering::Greater
+ } else {
+ Ordering::Equal
+ };
+ }
+ // self.pts * self.num / self.den vs other.pts * other.num / other.den
+ // ⇔ self.pts * self.num * other.den vs other.pts * other.num * self.den
+ let lhs = (self.pts as i128) * (self.timebase.num as i128) * (other.timebase.den.get() as i128);
+ let rhs =
+ (other.pts as i128) * (other.timebase.num as i128) * (self.timebase.den.get() as i128);
+ if lhs < rhs {
+ Ordering::Less
+ } else if lhs > rhs {
+ Ordering::Greater
+ } else {
+ Ordering::Equal
+ }
+ }
+
+ /// Returns the elapsed [`Duration`] from `earlier` to `self`, or `None` if
+ /// `earlier` is after `self`.
+ ///
+ /// Works across different timebases. Computes the exact rational difference
+ /// first using a common denominator, then truncates once when converting to
+ /// nanoseconds for the returned [`Duration`].
+ /// If the result would exceed `Duration::MAX` (pathological: seconds don't
+ /// fit in `u64`), saturates to `Duration::MAX` rather than wrapping.
+ #[cfg_attr(not(tarpaulin), inline(always))]
+ pub const fn duration_since(&self, earlier: &Self) -> Option {
+ const NS_PER_SEC: i128 = 1_000_000_000;
+
+ // Compute LCM of the two denominators via GCD so we can subtract in a
+ // common timebase without per-endpoint truncation.
+ let self_den = self.timebase.den.get();
+ let earlier_den = earlier.timebase.den.get();
+
+ let mut a = self_den;
+ let mut b = earlier_den;
+ while b != 0 {
+ let r = a % b;
+ a = b;
+ b = r;
+ }
+ let gcd = a as i128;
+
+ let self_scale = (earlier_den as i128) / gcd;
+ let earlier_scale = (self_den as i128) / gcd;
+ let common_den = (self_den as i128) * self_scale; // = lcm(self_den, earlier_den)
+
+ // Exact rational difference in units of 1/common_den seconds.
+ let diff_num = (self.pts as i128) * (self.timebase.num as i128) * self_scale
+ - (earlier.pts as i128) * (earlier.timebase.num as i128) * earlier_scale;
+ if diff_num < 0 {
+ return None;
+ }
+
+ // Single truncation: convert to whole seconds + nanosecond remainder.
+ let secs_i128 = diff_num / common_den;
+ if secs_i128 > u64::MAX as i128 {
+ return Some(Duration::MAX);
+ }
+ let rem = diff_num % common_den;
+ let nanos = (rem * NS_PER_SEC / common_den) as u32;
+ Some(Duration::new(secs_i128 as u64, nanos))
+ }
+}
+
+impl PartialEq for Timestamp {
+ #[cfg_attr(not(tarpaulin), inline(always))]
+ fn eq(&self, other: &Self) -> bool {
+ self.cmp_semantic(other).is_eq()
+ }
+}
+impl Eq for Timestamp {}
+
+impl Hash for Timestamp {
+ #[cfg_attr(not(tarpaulin), inline(always))]
+ fn hash(&self, state: &mut H) {
+ // Canonical representation: instant as reduced rational (pts * num, den).
+ let n: i128 = (self.pts as i128) * (self.timebase.num as i128);
+ let d: u128 = self.timebase.den.get() as u128;
+ // gcd operates on magnitudes; denominator stays positive. gcd ≥ 1 since d ≥ 1.
+ let g = gcd_u128(n.unsigned_abs(), d) as i128;
+ let rn = n / g;
+ let rd = (d as i128) / g;
+ rn.hash(state);
+ rd.hash(state);
+ }
+}
+
+impl Ord for Timestamp {
+ #[cfg_attr(not(tarpaulin), inline(always))]
+ fn cmp(&self, other: &Self) -> Ordering {
+ self.cmp_semantic(other)
+ }
+}
+impl PartialOrd for Timestamp {
+ #[cfg_attr(not(tarpaulin), inline(always))]
+ fn partial_cmp(&self, other: &Self) -> Option {
+ Some(self.cmp(other))
+ }
+}
+
+/// A half-open time range `[start, end)` in a given [`Timebase`].
+///
+/// Represents the extent of a detected event — for example, a fade-out →
+/// fade-in span. When `start == end`, the range is degenerate (an instant);
+/// see [`Self::instant`].
+///
+/// Both endpoints share the same [`Timebase`]. To compare ranges across
+/// different timebases, rescale one of them first (e.g., by calling
+/// [`Timestamp::rescale_to`] on each endpoint).
+#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
+pub struct TimeRange {
+ start: i64,
+ end: i64,
+ timebase: Timebase,
+}
+
+impl TimeRange {
+ /// Creates a new `TimeRange` with the given start/end PTS and shared timebase.
+ #[cfg_attr(not(tarpaulin), inline(always))]
+ pub const fn new(start: i64, end: i64, timebase: Timebase) -> Self {
+ Self {
+ start,
+ end,
+ timebase,
+ }
+ }
+
+ /// Creates a degenerate (instant) range where `start == end == ts.pts()`.
+ #[cfg_attr(not(tarpaulin), inline(always))]
+ pub const fn instant(ts: Timestamp) -> Self {
+ Self {
+ start: ts.pts(),
+ end: ts.pts(),
+ timebase: ts.timebase(),
+ }
+ }
+
+ /// Returns the start PTS in the range's timebase units.
+ #[cfg_attr(not(tarpaulin), inline(always))]
+ pub const fn start_pts(&self) -> i64 {
+ self.start
+ }
+
+ /// Returns the end PTS in the range's timebase units.
+ #[cfg_attr(not(tarpaulin), inline(always))]
+ pub const fn end_pts(&self) -> i64 {
+ self.end
+ }
+
+ /// Returns the shared timebase.
+ #[cfg_attr(not(tarpaulin), inline(always))]
+ pub const fn timebase(&self) -> Timebase {
+ self.timebase
+ }
+
+ /// Returns the start as a [`Timestamp`].
+ #[cfg_attr(not(tarpaulin), inline(always))]
+ pub const fn start(&self) -> Timestamp {
+ Timestamp::new(self.start, self.timebase)
+ }
+
+ /// Returns the end as a [`Timestamp`].
+ #[cfg_attr(not(tarpaulin), inline(always))]
+ pub const fn end(&self) -> Timestamp {
+ Timestamp::new(self.end, self.timebase)
+ }
+
+ /// Sets the start PTS.
+ #[cfg_attr(not(tarpaulin), inline(always))]
+ pub const fn with_start(mut self, val: i64) -> Self {
+ self.start = val;
+ self
+ }
+
+ /// Sets the start PTS in place.
+ #[cfg_attr(not(tarpaulin), inline(always))]
+ pub const fn set_start(&mut self, val: i64) -> &mut Self {
+ self.start = val;
+ self
+ }
+
+ /// Sets the end PTS.
+ #[cfg_attr(not(tarpaulin), inline(always))]
+ pub const fn with_end(mut self, val: i64) -> Self {
+ self.end = val;
+ self
+ }
+
+ /// Sets the end PTS in place.
+ #[cfg_attr(not(tarpaulin), inline(always))]
+ pub const fn set_end(&mut self, val: i64) -> &mut Self {
+ self.end = val;
+ self
+ }
+
+ /// Returns `true` if `start == end` (a degenerate instant range).
+ #[cfg_attr(not(tarpaulin), inline(always))]
+ pub const fn is_instant(&self) -> bool {
+ self.start == self.end
+ }
+
+ /// Returns the elapsed [`Duration`] from `start` to `end`, or `None` if
+ /// `end` is before `start`.
+ #[cfg_attr(not(tarpaulin), inline(always))]
+ pub const fn duration(&self) -> Option {
+ self.end().duration_since(&self.start())
+ }
+
+ /// Linearly interpolates between `start` and `end`: `t = 0.0` returns
+ /// `start`, `t = 1.0` returns `end`, `t = 0.5` the midpoint. `t` is
+ /// clamped to `[0.0, 1.0]`. Rounds toward zero.
+ ///
+ /// Use this to map an old-style bias value `b ∈ [-1, 1]` onto the range:
+ /// `range.interpolate((b + 1.0) * 0.5)`.
+ #[cfg_attr(not(tarpaulin), inline(always))]
+ pub const fn interpolate(&self, t: f64) -> Timestamp {
+ let t = t.clamp(0.0, 1.0);
+ let delta = self.end.saturating_sub(self.start);
+ let offset = (delta as f64 * t) as i64;
+ Timestamp::new(self.start.saturating_add(offset), self.timebase)
+ }
+}
+
+#[cfg_attr(not(tarpaulin), inline(always))]
+const fn gcd_u32(mut a: u32, mut b: u32) -> u32 {
+ while b != 0 {
+ let t = b;
+ b = a % b;
+ a = t;
+ }
+ a
+}
+
+#[cfg_attr(not(tarpaulin), inline(always))]
+const fn gcd_u128(mut a: u128, mut b: u128) -> u128 {
+ while b != 0 {
+ let t = b;
+ b = a % b;
+ a = t;
+ }
+ a
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+
+ const fn nz(n: u32) -> NonZeroU32 {
+ match NonZeroU32::new(n) {
+ Some(v) => v,
+ None => panic!("zero"),
+ }
+ }
+
+ fn hash_of(v: &T) -> u64 {
+ use std::collections::hash_map::DefaultHasher;
+ let mut h = DefaultHasher::new();
+ v.hash(&mut h);
+ h.finish()
+ }
+
+ #[test]
+ fn rescale_identity() {
+ let tb = Timebase::new(1, nz(1000));
+ assert_eq!(Timebase::rescale_pts(42, tb, tb), 42);
+ assert_eq!(tb.rescale(42, tb), 42);
+ }
+
+ #[test]
+ fn rescale_between_timebases() {
+ let ms = Timebase::new(1, nz(1000));
+ let mpeg = Timebase::new(1, nz(90_000));
+ assert_eq!(Timebase::rescale_pts(1000, ms, mpeg), 90_000);
+ assert_eq!(ms.rescale(1000, mpeg), 90_000);
+ assert_eq!(mpeg.rescale(90_000, ms), 1000);
+ }
+
+ #[test]
+ fn rescale_rounds_toward_zero() {
+ let from = Timebase::new(1, nz(1000));
+ let to = Timebase::new(1, nz(3));
+ assert_eq!(from.rescale(1, to), 0);
+ assert_eq!(from.rescale(-1, to), 0);
+ }
+
+ #[test]
+ fn rescale_saturates_on_i64_overflow() {
+ // Rescale from a coarse timebase (u32::MAX seconds per tick) to a fine
+ // one (1/u32::MAX seconds per tick): even a modest pts blows past
+ // i64::MAX in the 128-bit intermediate. `rescale_pts` should saturate
+ // to i64::MAX / i64::MIN rather than wrap via `as i64`.
+ let from = Timebase::new(u32::MAX, nz(1));
+ let to = Timebase::new(1, nz(u32::MAX));
+ assert_eq!(from.rescale(1_000_000, to), i64::MAX);
+ assert_eq!(from.rescale(-1_000_000, to), i64::MIN);
+ }
+
+ #[test]
+ fn timebase_eq_is_semantic() {
+ // 1/2 == 2/4 == 3/6
+ let a = Timebase::new(1, nz(2));
+ let b = Timebase::new(2, nz(4));
+ let c = Timebase::new(3, nz(6));
+ assert_eq!(a, b);
+ assert_eq!(b, c);
+ assert_eq!(a, c);
+ // 1/2 != 1/3
+ let d = Timebase::new(1, nz(3));
+ assert_ne!(a, d);
+ }
+
+ #[test]
+ fn timebase_hash_matches_eq() {
+ let a = Timebase::new(1, nz(2));
+ let b = Timebase::new(2, nz(4));
+ let c = Timebase::new(3, nz(6));
+ assert_eq!(hash_of(&a), hash_of(&b));
+ assert_eq!(hash_of(&b), hash_of(&c));
+ }
+
+ #[test]
+ fn timebase_ord_is_numeric() {
+ let third = Timebase::new(1, nz(3));
+ let half = Timebase::new(1, nz(2));
+ let two_thirds = Timebase::new(2, nz(3));
+ let one = Timebase::new(1, nz(1));
+ assert!(third < half);
+ assert!(half < two_thirds);
+ assert!(two_thirds < one);
+ // Structural lex order would have reported (1, 1) < (1, 3); verify it doesn't.
+ assert!(one > third);
+ }
+
+ #[test]
+ fn timebase_num_zero() {
+ // 0/3 == 0/5, and both compare less than anything positive.
+ let a = Timebase::new(0, nz(3));
+ let b = Timebase::new(0, nz(5));
+ assert_eq!(a, b);
+ assert_eq!(hash_of(&a), hash_of(&b));
+ assert!(a < Timebase::new(1, nz(1_000_000)));
+ }
+
+ #[test]
+ fn timestamp_cmp_same_timebase() {
+ let tb = Timebase::new(1, nz(1000));
+ let a = Timestamp::new(100, tb);
+ let b = Timestamp::new(200, tb);
+ assert!(a < b);
+ assert!(b > a);
+ assert_eq!(a, a);
+ assert_eq!(a.cmp(&b), Ordering::Less);
+ }
+
+ #[test]
+ fn timestamp_cmp_cross_timebase() {
+ let a = Timestamp::new(1000, Timebase::new(1, nz(1000)));
+ let b = Timestamp::new(90_000, Timebase::new(1, nz(90_000)));
+ assert_eq!(a, b);
+ assert_eq!(a.cmp(&b), Ordering::Equal);
+
+ let c = Timestamp::new(500, Timebase::new(1, nz(1000)));
+ assert!(c < a);
+ assert!(a > c);
+ }
+
+ #[test]
+ fn timestamp_hash_matches_semantic_eq() {
+ let a = Timestamp::new(1000, Timebase::new(1, nz(1000)));
+ let b = Timestamp::new(90_000, Timebase::new(1, nz(90_000)));
+ let c = Timestamp::new(2000, Timebase::new(1, nz(2000))); // also 1.0s
+ assert_eq!(a, b);
+ assert_eq!(hash_of(&a), hash_of(&b));
+ assert_eq!(hash_of(&a), hash_of(&c));
+ }
+
+ #[test]
+ fn timestamp_hash_negative_pts() {
+ // Pre-roll / edit list scenarios: -500 ms should equal -45_000 @ 1/90_000.
+ let a = Timestamp::new(-500, Timebase::new(1, nz(1000)));
+ let b = Timestamp::new(-45_000, Timebase::new(1, nz(90_000)));
+ assert_eq!(a, b);
+ assert_eq!(hash_of(&a), hash_of(&b));
+ }
+
+ #[test]
+ fn rescale_to_preserves_instant() {
+ let ms = Timebase::new(1, nz(1000));
+ let mpeg = Timebase::new(1, nz(90_000));
+ let a = Timestamp::new(1000, ms);
+ let b = a.rescale_to(mpeg);
+ assert_eq!(b.pts(), 90_000);
+ assert_eq!(b.timebase(), mpeg);
+ assert_eq!(a, b);
+ }
+
+ #[test]
+ fn duration_since_same_timebase() {
+ let tb = Timebase::new(1, nz(1000));
+ let a = Timestamp::new(1500, tb);
+ let b = Timestamp::new(500, tb);
+ assert_eq!(a.duration_since(&b), Some(Duration::from_millis(1000)));
+ assert_eq!(b.duration_since(&a), None);
+ }
+
+ #[test]
+ fn duration_since_cross_timebase() {
+ let a = Timestamp::new(1000, Timebase::new(1, nz(1000)));
+ let b = Timestamp::new(45_000, Timebase::new(1, nz(90_000)));
+ assert_eq!(a.duration_since(&b), Some(Duration::from_millis(500)));
+ }
+
+ #[test]
+ fn duration_since_saturates_to_duration_max_on_overflow() {
+ // Use a timebase of `u32::MAX / 1` (each tick ≈ 2^32 seconds). Then
+ // i64::MAX ticks ≈ 2^95 seconds — far more than u64::MAX. Should
+ // saturate to Duration::MAX rather than wrap when casting seconds to u64.
+ let tb = Timebase::new(u32::MAX, nz(1));
+ let huge = Timestamp::new(i64::MAX, tb);
+ let zero = Timestamp::new(0, tb);
+ assert_eq!(huge.duration_since(&zero), Some(Duration::MAX));
+ }
+
+ #[test]
+ fn frames_to_duration_integer_fps() {
+ let fps30 = Timebase::new(30, nz(1));
+ assert_eq!(fps30.frames_to_duration(15), Duration::from_millis(500));
+ assert_eq!(fps30.frames_to_duration(30), Duration::from_secs(1));
+ assert_eq!(fps30.frames_to_duration(0), Duration::ZERO);
+ }
+
+ #[test]
+ fn frames_to_duration_ntsc() {
+ // 30000 frames @ 30000/1001 fps = exactly 1001 seconds.
+ let ntsc = Timebase::new(30_000, nz(1001));
+ assert_eq!(ntsc.frames_to_duration(30_000), Duration::from_secs(1001));
+ // 15 frames at NTSC ≈ 500.5 ms.
+ assert_eq!(
+ ntsc.frames_to_duration(15),
+ Duration::from_nanos(500_500_000),
+ );
+ }
+
+ #[test]
+ fn time_range_basic() {
+ let tb = Timebase::new(1, nz(1000));
+ let r = TimeRange::new(100, 500, tb);
+ assert_eq!(r.start_pts(), 100);
+ assert_eq!(r.end_pts(), 500);
+ assert_eq!(r.timebase(), tb);
+ assert_eq!(r.start(), Timestamp::new(100, tb));
+ assert_eq!(r.end(), Timestamp::new(500, tb));
+ assert!(!r.is_instant());
+ assert_eq!(r.duration(), Some(Duration::from_millis(400)));
+ // Interpolate: t=0 → start, t=1 → end, t=0.5 → midpoint.
+ assert_eq!(r.interpolate(0.0).pts(), 100);
+ assert_eq!(r.interpolate(1.0).pts(), 500);
+ assert_eq!(r.interpolate(0.5).pts(), 300);
+ // Out-of-range t is clamped.
+ assert_eq!(r.interpolate(-1.0).pts(), 100);
+ assert_eq!(r.interpolate(2.0).pts(), 500);
+ }
+
+ #[test]
+ fn time_range_instant() {
+ let tb = Timebase::new(1, nz(1000));
+ let ts = Timestamp::new(123, tb);
+ let r = TimeRange::instant(ts);
+ assert!(r.is_instant());
+ assert_eq!(r.start_pts(), 123);
+ assert_eq!(r.end_pts(), 123);
+ assert_eq!(r.duration(), Some(Duration::ZERO));
+ }
+
+ // -------------------------------------------------------------------------
+ // Coverage top-ups — every public accessor, builder, and setter on the
+ // three types gets exercised at least once. Grouped per-type.
+ // -------------------------------------------------------------------------
+
+ #[test]
+ fn timebase_accessors_and_builders() {
+ let tb = Timebase::new(30_000, nz(1001));
+ assert_eq!(tb.num(), 30_000);
+ assert_eq!(tb.den(), nz(1001));
+
+ // with_num / with_den — consuming form.
+ let tb2 = tb.with_num(48_000).with_den(nz(1));
+ assert_eq!(tb2.num(), 48_000);
+ assert_eq!(tb2.den(), nz(1));
+
+ // set_num / set_den — in-place form. Returns &mut Self for chaining.
+ let mut tb3 = Timebase::new(1, nz(1000));
+ tb3.set_num(25).set_den(nz(2));
+ assert_eq!(tb3.num(), 25);
+ assert_eq!(tb3.den(), nz(2));
+ }
+
+ #[test]
+ fn duration_to_pts_happy_path_and_edge_cases() {
+ // Integer conversion: 1.5 s @ 1/1000 → 1500 units.
+ let ms = Timebase::new(1, nz(1000));
+ assert_eq!(ms.duration_to_pts(Duration::from_millis(1500)), 1500);
+ assert_eq!(ms.duration_to_pts(Duration::ZERO), 0);
+
+ // Non-ms timebase: 2 s @ 1/90_000 → 180_000 units.
+ let mpegts = Timebase::new(1, nz(90_000));
+ assert_eq!(mpegts.duration_to_pts(Duration::from_secs(2)), 180_000,);
+
+ // Degenerate: zero numerator → returns 0.
+ let degenerate = Timebase::new(0, nz(1));
+ assert_eq!(degenerate.duration_to_pts(Duration::from_secs(1)), 0,);
+
+ // Saturation at i64::MAX when the math would overflow.
+ // A frame rate of 1 fps (num=1, den=1 s) with an enormous duration:
+ // pts = ns * 1 / (1 * 1e9). Use a u64::MAX-ish nanos value via the
+ // max Duration; Rust's Duration max is ~(2^64 - 1) seconds.
+ let fps1 = Timebase::new(1, nz(1));
+ let huge = Duration::new(u64::MAX, 0);
+ assert_eq!(fps1.duration_to_pts(huge), i64::MAX);
+ }
+
+ #[test]
+ fn timestamp_accessors_and_builders() {
+ let tb = Timebase::new(1, nz(1000));
+ let mut ts = Timestamp::new(42, tb);
+ assert_eq!(ts.pts(), 42);
+ assert_eq!(ts.timebase(), tb);
+
+ // with_pts — consuming form.
+ let ts2 = ts.with_pts(777);
+ assert_eq!(ts2.pts(), 777);
+
+ // set_pts — in-place form, chainable.
+ ts.set_pts(-5).set_pts(-6);
+ assert_eq!(ts.pts(), -6);
+ }
+
+ #[test]
+ fn cmp_semantic_exercises_all_branches() {
+ let tb_a = Timebase::new(1, nz(1000)); // ms
+ let tb_b = Timebase::new(1, nz(90_000)); // MPEG-TS
+
+ // Same-timebase fast path: Less / Greater / Equal.
+ let a = Timestamp::new(100, tb_a);
+ let b = Timestamp::new(200, tb_a);
+ assert_eq!(a.cmp_semantic(&b), Ordering::Less);
+ assert_eq!(b.cmp_semantic(&a), Ordering::Greater);
+ assert_eq!(a.cmp_semantic(&a), Ordering::Equal);
+
+ // Cross-timebase slow path: Less / Greater / Equal.
+ let one_second_ms = Timestamp::new(1000, tb_a);
+ let one_second_mpg = Timestamp::new(90_000, tb_b);
+ let half_second_ms = Timestamp::new(500, tb_a);
+ let two_seconds_mpg = Timestamp::new(180_000, tb_b);
+ assert_eq!(half_second_ms.cmp_semantic(&one_second_mpg), Ordering::Less,);
+ assert_eq!(
+ two_seconds_mpg.cmp_semantic(&one_second_ms),
+ Ordering::Greater,
+ );
+ assert_eq!(one_second_ms.cmp_semantic(&one_second_mpg), Ordering::Equal,);
+ }
+
+ #[test]
+ fn saturating_sub_duration_saturates() {
+ let tb = Timebase::new(1, nz(1000));
+ // Subtracting a finite duration from a small pts shouldn't panic —
+ // it saturates at i64::MIN for pathological inputs.
+ let near_floor = Timestamp::new(i64::MIN + 10, tb);
+ let shifted = near_floor.saturating_sub_duration(Duration::from_secs(1));
+ assert_eq!(shifted.pts(), i64::MIN);
+
+ // Normal case: 1500 ms - 500 ms → 1000 ms.
+ let ts = Timestamp::new(1500, tb);
+ let shifted = ts.saturating_sub_duration(Duration::from_millis(500));
+ assert_eq!(shifted.pts(), 1000);
+ }
+
+ #[test]
+ fn time_range_builders_and_setters() {
+ let tb = Timebase::new(1, nz(1000));
+ let r = TimeRange::new(0, 0, tb);
+
+ // with_start / with_end — consuming form.
+ let r2 = r.with_start(100).with_end(500);
+ assert_eq!(r2.start_pts(), 100);
+ assert_eq!(r2.end_pts(), 500);
+
+ // set_start / set_end — in-place form, chainable.
+ let mut r3 = TimeRange::new(0, 0, tb);
+ r3.set_start(10).set_end(20);
+ assert_eq!(r3.start_pts(), 10);
+ assert_eq!(r3.end_pts(), 20);
+
+ // Reversed range: end before start means duration() is None.
+ let reversed = TimeRange::new(500, 100, tb);
+ assert!(reversed.duration().is_none());
+ }
+}
diff --git a/tests/foo.rs b/tests/foo.rs
deleted file mode 100644
index 8b13789..0000000
--- a/tests/foo.rs
+++ /dev/null
@@ -1 +0,0 @@
-