Found while testing crates/time/src/lib.rs:483 (parse_timecode, multi-field branch)
Co-Authored-By: Claude Haiku 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Require both ranges to be nonempty before comparing endpoints. This prevents zero and negative durations inside a clip from reporting a false overlap, matching is_empty and intersect.
Validation: 22 time tests and 69 edit tests pass; crate Clippy, formatting, layers and assets pass. Full CI is blocked by three existing UI nonminimal_bool diagnostics; contributor approved publishing with this blocker documented.
Co-authored-by: OpenAI Codex <noreply@openai.com>
Found while testing crates/time/src/lib.rs:423 (format_time, TimeDisplay::Seconds)
Co-Authored-By: Claude Haiku 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Found while testing crates/time/src/lib.rs:418 (format_time, TimeDisplay::AudioSamples)
Co-Authored-By: Claude Haiku 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
New Sequence From Clip used the median sample duration, so a 90 kHz track whose frame times were rounded to whole milliseconds misread 30 fps as 30.303 fps and 60 fps as 58.824 fps. Derive the track's nominal rate from the mean of durations near the median, then map it to the closest standard rate within 0.5% when present. Share that helper between media probing and frame-grid snapping, and add regression tests for 30 and 60 fps.
Signed-off-by: Earl Co <hello@tekiz.ai>
tick_of() used floor division (div_euclid) which truncated the frame
duration for non-standard rates like 37.516 fps (9379/250) where the
frame duration is 6770871094.999 ticks, not an integer. This caused
frame_at(tick_of(f)) to return f-1 instead of f, making step forward
stall at frame 0 and step back jump backward by 2+ frames.
Changed tick_of() to use ceiling division so the round-trip is exact
for all integer frame indices. Ceiling is correct because the frame
duration is always > 1 tick, so floor(ceil(f*D) / D) == f for every
integer f. frame_duration() remains floor division (unchanged) since
many callers do end - frame_duration() to get the previous frame.
All 11 standard broadcast rates are unaffected since their frame
durations are already exact integers (ceil == floor there).
Added regression tests:
- exotic_frame_rate_roundtrip: round-trip over -20k..20k for 5
non-standard rates, snap invariant, step-back via frame_duration,
forward/backward stepping simulation
- exotic_frame_rate_durations: frame_duration is floor for exotic,
exact for standard
- exotic_frame_tick_roundtrip proptest: arbitrary FrameRate::new(num,
den) round-trip
Thanks to @justjohn12345 for catching that round (not ceil) still
fails for ~50% of frames starting at f=938.
Problem (#73): a 30 fps WebM played and exported with every third frame
missing and the frame before it shown twice (20 unique frames a second).
The same happens at 29.97, 60, 59.94, 24 and 23.976 fps, and with MP4
files whose video track has a 1 ms timescale. 25 and 50 fps were fine.
Cause: Matroska stores block timestamps in TimestampScale units (usually
1 ms), rounded to the nearest unit, so at 30 fps frame 2 (66.67 ms) is
stored as 67. MkvSource::video_frame converted the requested time to track
units with a floor (66), and the lookup (last sample with pts <= target)
returned frame 1. Every frame whose timestamp was rounded up was skipped.
Mp4Source::video_at, and the web build's WebCodecs MP4 path, floored the
same way against the track timescale; normal MP4 timescales hold frame
times exactly, so only coarse (1000) timescales were hit.
Fix: new Tick::to_rational_round (nearest unit, halves up, total for zero
and negative timebases), used for the video lookup target and the
catch-up threshold in mkv.rs, mp4.rs and webcodecs.rs. A requested time
now matches a frame whose stored timestamp is within half a unit of it,
the same precision the timestamps have. Truncating muxers (stamps rounded
down) still work. Times on exact timebases (90 kHz, 15360, 30000/1001…)
convert exactly as before.
Test: crates/codecs/src/rounded_pts_tests.rs builds synthetic Motion-JPEG
streams in the test (each picture shows its index as black/white blocks),
muxed as WebM with 1 ms timestamps and as MOV with a 1000 timescale, at
30, 29.97, 60, 59.94, 24, 23.976 and 25 fps with nearest and truncated
stamps. Requesting every frame's start over 3 s must show every frame
exactly once, and every frame's midpoint must show that frame. Before the
fix both tests failed ("30 fps, Nearest ms stamps: 30 of 90 frames never
shown [2, 5, 8, 11]…, 30 shown twice [1, 4, 7, 10]…"). The time crate
tests cover the rounding, ties and hostile timebases.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Mutation fuzzing found two crashes on import: an MP4 track with timescale 0 panicked in
`Tick::from_rational` (division by zero) and an MPEG-TS stream whose timestamps gave a zero
frame rate panicked in `FrameRate::tick_of`. A FrameRate with a zero num/den can also come from a
project file (it's serde).
- `Tick::{from_units, from_rational, to_rational_floor, mul_ratio}` return 0 for a zero
denominator instead of dividing by zero.
- `FrameRate::sane()`: an invalid (zero / negative) rate counts as the default rate in
`frame_duration`, `frame_at`, `tick_of` and `timecode_base`.
- `Tick::clamp` no longer panics when the bounds cross.
- Timecode parsing and drop-frame conversion use saturating math (huge typed or agent-sent
timecodes overflowed).
- Regression test `hostile_rates_and_timecodes_never_panic`.
- `#![deny(clippy::unwrap_used, …)]` outside tests.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>