Skip to content

Commit 6ae33f0

Browse files
committed
Generalize NTSC framerate detection
1 parent 71f4089 commit 6ae33f0

2 files changed

Lines changed: 111 additions & 27 deletions

File tree

tests/test_timecode.py

Lines changed: 62 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -247,6 +247,12 @@ def test_timecode_properties_test(args, kwargs, hrs, mins, secs, frs, str_repr):
247247
[["59.94", "13:36:59;59"], {}, None, None, "13:37:00;04"],
248248
[["59.94", "13:39:59;59"], {}, None, None, "13:40:00;00"],
249249
[["29.97", "13:39:59;29"], {}, None, None, "13:40:00;00"],
250+
[["89.91", "00:00:00;00"], {}, 1, None, None],
251+
[["89.91", "00:00:00;89"], {}, 90, None, None],
252+
[["89.91", "00:00:01;00"], {}, 91, None, None],
253+
[["89.91", "00:01:00;00"], {}, 5395, "00:00:59;84", None],
254+
[["89.91", "13:36:59;89"], {}, None, None, "13:37:00;06"],
255+
[["89.91", "13:39:59;89"], {}, None, None, "13:40:00;00"],
250256
[["119.88", "00:00:00;00"], {}, 1, None, None],
251257
[["119.88", "00:00:00;119"], {}, 120, None, None],
252258
[["119.88", "00:00:01;00"], {}, 121, None, None],
@@ -270,10 +276,10 @@ def test_ntsc_drop_frame_conversion(args, kwargs, frames, str_repr, tc_next):
270276

271277

272278
@pytest.mark.parametrize(
273-
"framerate", ["29.97", "59.94", "119.88"]
279+
"framerate", ["29.97", "59.94", "89.91", "119.88"]
274280
)
275281
def test_setting_ntsc_frame_rate_forces_drop_frame(framerate):
276-
"""Setting NTSC frame rates forces the dropframe to True."""
282+
"""Setting NTSC drop frame rates forces the dropframe to True."""
277283
tc = Timecode(framerate)
278284
assert tc.drop_frame
279285

@@ -345,7 +351,6 @@ def test_iteration(args, kwargs, str_repr, next_range, last_tc_str_repr, frames)
345351
[["119.88", "03:36:09;23"],{}, ["119.88", "00:00:29;23"],{}, 3504, 3504, "03:36:38;47", "03:36:38;47", 1558248, 1558248],
346352
[["23.98", "03:36:09:23"], {}, ["23.98", "00:00:29:23"], {}, 720, 720, "03:36:39:23", "03:36:39:23", 312000, 312000],
347353
[["ms", "03:36:09.230"], {}, ["ms", "01:06:09.230"], {}, 3969231, 720, "04:42:18.461", "03:36:09.950", 16938462, 12969951],
348-
[["ms", "03:36:09.230"], {}, ["ms", "01:06:09.230"], {}, 3969231, 720, "04:42:18.461", "03:36:09.950", 16938462, 12969951],
349354
[["24"], {"frames": 12000}, ["24"], {"frames": 485}, 485, 719, "00:08:40:04", "00:08:49:22", 12485, 12719],
350355
[["59.94", "04:20:13;21"], {}, ["59.94", "23:59:59;59"], {}, 5178816, 0, "04:20:13;21", "04:20:13;21", 6114682, 935866],
351356
]
@@ -410,7 +415,6 @@ def test_op_overloads_subtract(args1, kwargs1, args2, kwargs2, custom_offset1, c
410415
[["119.88", "03:36:09;23"], {}, ["119.88", "00:00:29;23"], {}, 3504, 3504, "23:19:28;95", "23:19:28;95", 5447822976, 5447822976],
411416
[["ms", "03:36:09.230"], {}, ["ms", "01:06:09.230"], {}, 3969231, 3969231, "17:22:11.360", "17:22:11.360", 51477873731361, 51477873731361],
412417
[["24"], {"frames": 12000}, ["24"], {"frames": 485}, 485, 485, "19:21:39:23", "19:21:39:23", 5820000, 5820000],
413-
[["24"], {"frames": 12000}, ["24"], {"frames": 485}, 485, 485, "19:21:39:23", "19:21:39:23", 5820000, 5820000],
414418
]
415419
)
416420
def test_op_overloads_mult(args1, kwargs1, args2, kwargs2, custom_offset1, custom_offset2, str_repr1, str_repr2, frames1, frames2):
@@ -509,6 +513,7 @@ def test_div_method_working_properly_2():
509513
[["59.94", "00:01:00;00"], 3597, 3596],
510514
[["60", "00:01:00:00"], 3601, 3600],
511515
[["72", "00:01:00:00"], 4321, 4320],
516+
[["89.91", "00:01:00;00"], 5395, 5394],
512517
[["96", "00:01:00:00"], 5761, 5760],
513518
[["100", "00:01:00:00"], 6001, 6000],
514519
[["120", "00:01:00:00"], 7201, 7200],
@@ -1087,6 +1092,8 @@ def test_rollover_for_23_98():
10871092
[["59.94"], {"frames": 5184001, "force_non_drop_frame": True}, "00:00:00:00"],
10881093
[["72"], {"frames": 6220800}, "23:59:59:71"],
10891094
[["72"], {"frames": 6220801}, "00:00:00:00"],
1095+
[["89.91"], {"frames": 7768224}, "23:59:59;89"],
1096+
[["89.91"], {"frames": 7768225}, "00:00:00;00"],
10901097
[["96"], {"frames": 8294400}, "23:59:59:95"],
10911098
[["96"], {"frames": 8294401}, "00:00:00:00"],
10921099
[["100"], {"frames": 8640000}, "23:59:59:99"],
@@ -1100,3 +1107,54 @@ def test_rollover_for_23_98():
11001107
def test_rollover(args, kwargs, str_repr):
11011108
tc = Timecode(*args, **kwargs)
11021109
assert str_repr == tc.__str__()
1110+
1111+
1112+
@pytest.mark.parametrize(
1113+
"framerate,int_framerate,is_drop,one_minute_frames,expected_tc", [
1114+
# Non-drop NTSC rates (multiples of 24000/1001)
1115+
["47.952", 48, False, 2881, "00:01:00:00"], # 2 * 23.976 fps - HFR broadcast
1116+
["71.928", 72, False, 4321, "00:01:00:00"], # 3 * 23.976 fps
1117+
["95.904", 96, False, 5761, "00:01:00:00"], # 4 * 23.976 fps
1118+
# Drop frame NTSC rate (multiple of 30000/1001)
1119+
# For drop frame, test at 10-minute mark where frames aren't skipped
1120+
["89.91", 90, True, 53947, "00:10:00;00"], # 3 * 29.97 fps - with drop frame
1121+
]
1122+
)
1123+
def test_generalized_ntsc_rates(framerate, int_framerate, is_drop, one_minute_frames, expected_tc):
1124+
"""Test generalized NTSC detection for HFR rates.
1125+
1126+
Tests automatic NTSC detection for rates based on multiples of 24000/1001 or 30000/1001.
1127+
Drop frame should only apply to multiples of 30000/1001 (i.e., int_framerate % 30 == 0).
1128+
"""
1129+
# Test basic creation and NTSC detection
1130+
separator = ";" if is_drop else ":"
1131+
tc = Timecode(framerate, f"00:00:00{separator}00")
1132+
assert tc._ntsc_framerate is True
1133+
assert tc._int_framerate == int_framerate
1134+
assert tc.drop_frame is is_drop
1135+
assert tc.framerate == framerate
1136+
1137+
# Test frame counting - one second should be int_framerate + 1
1138+
tc2 = Timecode(framerate, f"00:00:01{separator}00")
1139+
assert tc2.frames == int_framerate + 1
1140+
1141+
# Test frame count displays correctly
1142+
tc3 = Timecode(framerate, frames=one_minute_frames)
1143+
assert str(tc3) == expected_tc
1144+
1145+
1146+
@pytest.mark.parametrize(
1147+
"rational_str,int_framerate,is_drop", [
1148+
["48000/1001", 48, False], # 47.952 fps
1149+
["72000/1001", 72, False], # 71.928 fps
1150+
["90000/1001", 90, True], # 89.91 fps - drop frame
1151+
["96000/1001", 96, False], # 95.904 fps
1152+
]
1153+
)
1154+
def test_generalized_ntsc_rational_formats(rational_str, int_framerate, is_drop):
1155+
"""Test that rational format fractions work for new NTSC rates."""
1156+
separator = ";" if is_drop else ":"
1157+
tc = Timecode(rational_str, f"00:00:00{separator}00")
1158+
assert tc._ntsc_framerate is True
1159+
assert tc._int_framerate == int_framerate
1160+
assert tc.drop_frame is is_drop

timecode/__init__.py

Lines changed: 49 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -68,6 +68,31 @@ class Timecode(object):
6868
default.
6969
"""
7070

71+
@staticmethod
72+
def _is_ntsc_rate(fps: float) -> Tuple[bool, int]:
73+
"""Check if framerate is NTSC (multiple of 24000/1001 or 30000/1001).
74+
75+
NTSC rates follow the pattern: nominal_rate * 1000/1001
76+
Examples: 23.976, 29.97, 47.952, 59.94, 71.928, 89.91, 95.904, 119.88
77+
78+
Args:
79+
fps (float): The framerate to check.
80+
81+
Returns:
82+
tuple: (is_ntsc, int_framerate) where is_ntsc is True if this is an NTSC rate,
83+
and int_framerate is the rounded integer framerate.
84+
"""
85+
# Calculate what the integer framerate would be if this is NTSC
86+
int_fps = round(fps * 1001 / 1000)
87+
88+
# Calculate what the NTSC rate would be for this integer framerate
89+
expected_ntsc = int_fps * 1000 / 1001
90+
91+
# Check if the input matches expected NTSC rate (within tolerance)
92+
is_ntsc = abs(fps - expected_ntsc) < 0.005
93+
94+
return is_ntsc, int_fps
95+
7196
def __init__(
7297
self,
7398
framerate: Union[str, int, float, Fraction],
@@ -187,30 +212,31 @@ def framerate(self, framerate: Union[int, float, str, Tuple[int, int], Fraction]
187212

188213
self._ntsc_framerate = False
189214

190-
# set the int_frame_rate
191-
if framerate == "29.97":
192-
self._int_framerate = 30
193-
self.drop_frame = not self.force_non_drop_frame
194-
self._ntsc_framerate = True
195-
elif framerate == "59.94":
196-
self._int_framerate = 60
197-
self.drop_frame = not self.force_non_drop_frame
198-
self._ntsc_framerate = True
199-
elif framerate == "119.88":
200-
self._int_framerate = 120
201-
self.drop_frame = not self.force_non_drop_frame
202-
self._ntsc_framerate = True
203-
elif any(map(lambda x: framerate.startswith(x), ["23.976", "23.98"])): # type: ignore
204-
self._int_framerate = 24
205-
self._ntsc_framerate = True
206-
elif framerate in ["ms", "1000"]:
215+
# Handle special cases first
216+
if framerate in ["ms", "1000"]:
207217
self._int_framerate = 1000
208218
self.ms_frame = True
209219
framerate = 1000
210220
elif framerate == "frames":
211221
self._int_framerate = 1
212222
else:
213-
self._int_framerate = int(float(framerate)) # type: ignore
223+
# Try to detect NTSC rates
224+
try:
225+
fps = float(framerate) # type: ignore
226+
is_ntsc, int_fps = self._is_ntsc_rate(fps)
227+
228+
if is_ntsc:
229+
self._ntsc_framerate = True
230+
self._int_framerate = int_fps
231+
# DF only for multiples of 30000/1001 (29.97, 59.94, etc.).
232+
if int_fps % 30 == 0:
233+
self.drop_frame = not self.force_non_drop_frame
234+
else:
235+
# Non-NTSC rate, use integer value
236+
self._int_framerate = int(fps)
237+
except (ValueError, TypeError):
238+
# If conversion fails, fall back to direct integer conversion
239+
self._int_framerate = int(float(framerate)) # type: ignore
214240

215241
self._framerate = framerate # type: ignore
216242

@@ -271,7 +297,7 @@ def tc_to_frames(self, timecode: Union[str, "Timecode"]) -> int:
271297
if self.framerate != "frames":
272298
ffps = float(self.framerate)
273299
else:
274-
ffps = float(self._int_framerate)
300+
ffps = float(self._int_framerate)
275301

276302
if self.drop_frame:
277303
# Number of drop frames is 6% of framerate rounded to nearest
@@ -358,7 +384,7 @@ def frames_to_tc(self, frames: int, skip_rollover: bool = False) -> Tuple[int, i
358384

359385
frs: Union[int, float] = frame_number % ifps
360386
if self.fraction_frame:
361-
frs = round(frs / float(ifps), 3)
387+
frs = round(frs / float(ifps), 3)
362388

363389
secs = int((frame_number // ifps) % 60)
364390
mins = int(((frame_number // ifps) // 60) % 60)
@@ -405,7 +431,7 @@ def to_systemtime(self, as_float: bool = False) -> Union[str, float]: # type:ig
405431
For NTSC rates, the video system time is not the wall-clock one.
406432
407433
Args:
408-
as_float (bool): Return the time as a float number of seconds.
434+
as_float (bool): Return the time as a float number of seconds.
409435
410436
Returns:
411437
str: The "system time" timestamp of the Timecode.
@@ -447,7 +473,7 @@ def to_realtime(self, as_float: bool = False) -> Union[str, float]: # type:igno
447473
if self.ms_frame:
448474
return ts_float-(1e-3) if as_float else str(self)
449475

450-
# "int_framerate" frames is one second in NTSC time
476+
# "int_framerate" frames is one second in NTSC time
451477
if self._ntsc_framerate:
452478
ts_float *= 1.001
453479
if as_float:
@@ -598,7 +624,7 @@ def __eq__(self, other: Union[int, str, "Timecode", object]) -> bool:
598624
return self.__eq__(new_tc)
599625
elif isinstance(other, int):
600626
return self.frames == other
601-
else:
627+
else:
602628
return False
603629

604630
def __ge__(self, other: Union[int, str, "Timecode", object]) -> bool:

0 commit comments

Comments
 (0)