Skip to content

Commit a02f4be

Browse files
committed
[common] Calculate framerate as Fraction rather than using lookup table
1 parent 9fa2c64 commit a02f4be

2 files changed

Lines changed: 36 additions & 17 deletions

File tree

scenedetect/common.py

Lines changed: 14 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -91,37 +91,34 @@
9191
MAX_FPS_DELTA: float = 1.0 / 1000000000.0
9292
"""Maximum amount two framerates can differ by for equality testing. Currently 1 frame/nanosec."""
9393

94+
# `datetime.timedelta` does not expose seconds per minute/hour as constants, so we define our own.
9495
_SECONDS_PER_MINUTE = 60.0
9596
_SECONDS_PER_HOUR = 60.0 * _SECONDS_PER_MINUTE
9697
_MINUTES_PER_HOUR = 60.0
9798

98-
# Common framerates mapped from their float representation to exact rational values.
99-
_COMMON_FRAMERATES: dict[Fraction, Fraction] = {
100-
Fraction(24000, 1001): Fraction(24000, 1001), # 23.976...
101-
Fraction(30000, 1001): Fraction(30000, 1001), # 29.97...
102-
Fraction(60000, 1001): Fraction(60000, 1001), # 59.94...
103-
Fraction(120000, 1001): Fraction(120000, 1001), # 119.88...
104-
}
99+
# Tolerance for snapping a float value's framerate to an NTSC-derived rational (N * 1000/1001).
100+
# e.g. 23.976 should be detected as 24000/1001, 29.97 should be detected as 30000/1001, etc.
101+
_NTSC_DETECTION_TOLERANCE: float = 1e-3
105102

106103

107104
def framerate_to_fraction(fps: float) -> Fraction:
108105
"""Convert a float framerate to an exact rational Fraction.
109106
110-
Recognizes common NTSC framerates (23.976, 29.97, 59.94, 119.88) and maps them to their
111-
exact rational representation (e.g. 24000/1001). For other values, uses limit_denominator
112-
to find a clean rational approximation, or returns the exact integer fraction for whole
113-
number framerates.
107+
Detects NTSC-derived framerates of the form ``N * 1000/1001`` (e.g. 23.976 -> 24000/1001,
108+
29.97 -> 30000/1001, 47.952 -> 48000/1001) for any positive integer ``N`` and returns
109+
their exact rational representation. Whole-number framerates are returned as
110+
``Fraction(N, 1)``. Other values fall back to ``limit_denominator(10000)`` for a clean
111+
rational approximation.
114112
"""
115113
if fps <= MAX_FPS_DELTA:
116114
raise ValueError("Framerate must be positive and greater than zero.")
117-
# Integer framerates are exact.
118115
if fps == int(fps):
119116
return Fraction(int(fps), 1)
120-
# Check against known common framerates using limit_denominator to find the closest match.
121-
candidate = Fraction(fps).limit_denominator(10000)
122-
if candidate in _COMMON_FRAMERATES:
123-
return _COMMON_FRAMERATES[candidate]
124-
return candidate
117+
# Invert fps = N * 1000/1001 to recover N, then verify within tolerance.
118+
base = round(fps * 1001 / 1000)
119+
if base > 0 and abs(base * 1000 / 1001 - fps) < _NTSC_DETECTION_TOLERANCE:
120+
return Fraction(base * 1000, 1001)
121+
return Fraction(fps).limit_denominator(10000)
125122

126123

127124
class Interpolation(Enum):

tests/test_timecode.py

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -316,12 +316,34 @@ def test_ntsc_framerate_detection():
316316
assert framerate_to_fraction(23.976023976023978) == Fraction(24000, 1001)
317317
assert framerate_to_fraction(29.97002997002997) == Fraction(30000, 1001)
318318
assert framerate_to_fraction(59.94005994005994) == Fraction(60000, 1001)
319+
assert framerate_to_fraction(119.88011988011988) == Fraction(120000, 1001)
319320
assert framerate_to_fraction(24.0) == Fraction(24, 1)
320321
assert framerate_to_fraction(30.0) == Fraction(30, 1)
321322
assert framerate_to_fraction(60.0) == Fraction(60, 1)
322323
assert framerate_to_fraction(25.0) == Fraction(25, 1)
323324

324325

326+
def test_ntsc_framerate_detection_arbitrary_base():
327+
"""NTSC detection should work for any base rate, not a hardcoded list (e.g. 48000/1001
328+
for HFR cinema)."""
329+
assert framerate_to_fraction(47.952047952047955) == Fraction(48000, 1001)
330+
assert framerate_to_fraction(239.76023976023975) == Fraction(240000, 1001)
331+
332+
333+
def test_ntsc_framerate_detection_low_precision():
334+
"""Low-precision float reports (e.g. truncated to 3 decimals) should still snap to the
335+
NTSC rational."""
336+
assert framerate_to_fraction(23.976) == Fraction(24000, 1001)
337+
assert framerate_to_fraction(29.97) == Fraction(30000, 1001)
338+
339+
340+
def test_framerate_to_fraction_non_ntsc_fallback():
341+
"""Non-NTSC, non-integer framerates should fall back to limit_denominator and not be
342+
misclassified as NTSC."""
343+
# 24.5 is not near any N*1000/1001 within tolerance, so the limit_denominator path runs.
344+
assert framerate_to_fraction(24.5) == Fraction(49, 2)
345+
346+
325347
def test_timecode_arithmetic_mixed_time_base():
326348
"""Arithmetic with FrameTimecodes using different time_bases should work."""
327349
fps = Fraction(24000, 1001)

0 commit comments

Comments
 (0)