From dfda630b78c8bf4aa8836b51e079df4957b55c2d Mon Sep 17 00:00:00 2001 From: imSp4rky Date: Sat, 14 Nov 2026 11:17:07 -0700 Subject: [PATCH] fix(mux): pass the exact frame rate to mkvmerge default duration FPS.exact keeps 24000/1001 instead of rounding to 23.976, and the hybrid source copies no longer set needs_duration_fix, which nothing read. --- tests/core/test_fps.py | 29 +++++++++++++++++++++++++++++ tests/core/test_hybrid_dv_fixup.py | 1 - tests/core/test_hybrid_hdr10plus.py | 2 +- unshackle/commands/dl.py | 2 -- unshackle/core/tracks/tracks.py | 3 ++- unshackle/core/utilities.py | 8 ++++++++ 6 files changed, 40 insertions(+), 5 deletions(-) diff --git a/tests/core/test_fps.py b/tests/core/test_fps.py index d8af6cd1..1f074b12 100644 --- a/tests/core/test_fps.py +++ b/tests/core/test_fps.py @@ -1,7 +1,10 @@ import warnings +from pathlib import Path +from typing import Callable import pytest +from unshackle.core.tracks import Tracks, Video from unshackle.core.utilities import FPS @@ -27,3 +30,29 @@ def test_parse_rejects_non_numeric_constant() -> None: def test_parse_rejects_non_division_operation() -> None: with pytest.raises(ValueError, match="Invalid operation"): FPS.parse("24+1") + + +@pytest.mark.parametrize( + ("fps", "expected"), + [ + (24000 / 1001, "24000/1001"), + (23.976, "24000/1001"), + (23.97, "24000/1001"), + (59.94, "60000/1001"), + (24.0, "24"), + (25, "25"), + (5, "5"), + (12.5, "25/2"), + ], +) +def test_exact_keeps_ntsc_rates(fps: float, expected: str) -> None: + assert str(FPS.exact(fps)) == expected + + +def test_mux_passes_the_exact_rate_as_default_duration(tmp_path: Path, mux_args: Callable[..., list[str]]) -> None: + video = Video(id_="v1", url="https://example.test/v", codec=Video.Codec.HEVC, language="en", fps="24000/1001") + video.needs_duration_fix = True + video.path = tmp_path / "v.hevc" + video.path.write_bytes(b"\x00" * 16) + args = mux_args(Tracks(video)) + assert args[args.index("--default-duration") + 1] == "0:24000/1001p" diff --git a/tests/core/test_hybrid_dv_fixup.py b/tests/core/test_hybrid_dv_fixup.py index a1dd594d..397df579 100644 --- a/tests/core/test_hybrid_dv_fixup.py +++ b/tests/core/test_hybrid_dv_fixup.py @@ -71,7 +71,6 @@ def test_hybrid_step_reads_the_container_after_the_fixup(fake_demux: None, tmp_p assert base.path == tmp_path / "base.dv.hevc" assert hybrid_base.path == tmp_path / "base.mp4" assert hybrid_dv.path == dv.path - assert hybrid_base.needs_duration_fix and hybrid_dv.needs_duration_fix def test_one_hybrid_base_for_each_height(tmp_path: Path) -> None: diff --git a/tests/core/test_hybrid_hdr10plus.py b/tests/core/test_hybrid_hdr10plus.py index 9c4dbb30..702e7bfd 100644 --- a/tests/core/test_hybrid_hdr10plus.py +++ b/tests/core/test_hybrid_hdr10plus.py @@ -779,7 +779,7 @@ def test_the_hybrid_step_gets_the_base_alone_when_there_is_no_dv(tmp_path: Path) tracks = hybrid_source_tracks(base, None) # type: ignore[arg-type] - assert len(tracks) == 1 and tracks[0].range == Video.Range.HDR10P and tracks[0].needs_duration_fix + assert len(tracks) == 1 and tracks[0].range == Video.Range.HDR10P def test_the_api_takes_generate_dv_with_any_range_as_dl_does() -> None: diff --git a/unshackle/commands/dl.py b/unshackle/commands/dl.py index 73c3145f..bb598687 100644 --- a/unshackle/commands/dl.py +++ b/unshackle/commands/dl.py @@ -385,8 +385,6 @@ def hybrid_source_tracks(base: Video, dv: Optional[Video], base_container: Optio """ tracks = [deepcopy(track) for track in (base, dv) if track] tracks[0].path = base_container or base.path - for track in tracks: - track.needs_duration_fix = True return tracks diff --git a/unshackle/core/tracks/tracks.py b/unshackle/core/tracks/tracks.py index 3ba286ca..19bc0bc3 100644 --- a/unshackle/core/tracks/tracks.py +++ b/unshackle/core/tracks/tracks.py @@ -30,6 +30,7 @@ from unshackle.core.tracks.subtitle import Subtitle from unshackle.core.tracks.track import Track, has_mp4_only_audio_entry, strip_duplicate_init_boxes from unshackle.core.tracks.video import Video from unshackle.core.utilities import ( + FPS, is_close_match, log_event, matching_languages, @@ -991,7 +992,7 @@ class Tracks: video_args.extend( [ "--default-duration", - f"{v}:{vt.fps}fps" if isinstance(vt.fps, str) else f"{v}:{vt.fps:.3f}fps", + f"{v}:{FPS.exact(vt.fps)}p", "--fix-bitstream-timing-information", f"{v}:1", ] diff --git a/unshackle/core/utilities.py b/unshackle/core/utilities.py index 4180b0c5..e4761aee 100644 --- a/unshackle/core/utilities.py +++ b/unshackle/core/utilities.py @@ -16,6 +16,7 @@ import unicodedata import zlib from collections import defaultdict from datetime import datetime, timezone +from fractions import Fraction from functools import cache from pathlib import Path from types import ModuleType @@ -1086,6 +1087,13 @@ class FPS(ast.NodeVisitor): def parse(cls, expr: str) -> float: return cls().visit(ast.parse(expr).body[0]) + @staticmethod + def exact(fps: float) -> Fraction: + """The frame rate as a fraction; a rate within 0.05% of an N*1000/1001 rate becomes that rate.""" + rate = Fraction(fps) + ntsc = Fraction(round(rate * Fraction(1001, 1000)) * 1000, 1001) + return ntsc if abs(rate - ntsc) < rate / 2000 else rate.limit_denominator(1001) + """ Structured JSON debug logging for unshackle.