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][Github-url] -LoC -[Build][CI-url] -[codecov][codecov-url] - -[docs.rs][doc-url] -[crates.io][crates-url] -[crates.io][crates-url] -license - -[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][Github-url] -LoC -[Build][CI-url] -[codecov][codecov-url] +[github][Github-url] +LoC +[Build][CI-url] +[codecov][codecov-url] -[docs.rs][doc-url] -[crates.io][crates-url] -[crates.io][crates-url] +[docs.rs][doc-url] +[crates.io][crates-url] +[crates.io][crates-url] license -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 @@ -