diff --git a/README.md b/README.md index 559d0dd..d85f8a1 100644 --- a/README.md +++ b/README.md @@ -1,19 +1,21 @@ About ----- -Python module for manipulating SMPTE timecode. Supports any arbitrary integer frame -rates and some default str values of 23.976, 23.98, 24, 25, 29.97, 30, 50, 59.94, 60 -frame rates and milliseconds (1000 fps) and fractional frame rates like "30001/1001". +Python module for manipulating SMPTE timecode. Supports all formats in the ST12 +standard, as well as any arbitrary integer frame rates and some default str +values of 23.976, 23.98, 24, 25, 29.97, 30, 50, 59.94, 60 frame rates and +milliseconds (1000 fps) and fractional frame rates like "30000/1001". -This library is a fork of the original PyTimeCode python library. You should not use -the two library together (PyTimeCode is not maintained and has known bugs). +This library is a fork of the original PyTimeCode python library. You should +not use the two library together (PyTimeCode is not maintained and has known +bugs). The math behind the drop frame calculation is based on the [blog post of David Heidelberger](http://www.davidheidelberger.com/blog/?p=29). -Simple math operations like, addition, subtraction, multiplication or division with an -integer value or with a timecode is possible. Math operations between timecodes with -different frame rates are supported. So: +Simple math operations like, addition, subtraction, multiplication or division +with an integer value or with a timecode is possible. Math operations between +timecodes with different frame rates are supported. So: ```py from timecode import Timecode @@ -26,8 +28,8 @@ assert tc3.frames == 12 assert tc3 == '00:00:00:11' ``` -Creating a Timecode instance with a start timecode of '00:00:00:00' will result a -timecode object where the total number of frames is 1. So: +Creating a Timecode instance with a start timecode of '00:00:00:00' will result +a timecode object where the total number of frames is 1. So: ```py tc4 = Timecode('24', '00:00:00:00') @@ -41,14 +43,14 @@ assert tc4.frame_number == 0 ``` > [!NOTE] -> A common misconception is that `00:00:00:00` should have 0 frames. This is wrong -> because Timecode is a label given for each frame in a media, and it happens to be -> using numbers which are seemingly incremented one after another. So, for a Timecode to -> exist there should be a frame. and 00:00:00:00 is generally the label given to the -> first frame. +> A common misconception is that `00:00:00:00` should have 0 frames. This is +> wrong because Timecode is a label given for each frame in a media, and it +> happens to be using numbers which are seemingly incremented one after +> another. So, for a Timecode to exist there should be a frame. and 00:00:00:00 +> is generally the label given to the first frame. -Frame rates 29.97 and 59.94 are always drop frame, and all the others are non drop -frame. +Frame rates 29.97, 59.94 and 119.88 are always drop frame, and all the others +are non drop frame. The timecode library supports fractional frame rates passed as a string: @@ -67,8 +69,8 @@ assert repr(tc6) == '19:23:14:23' This is useful for parsing timecodes stored in OpenEXR's and extracted through OpenImageIO for instance. -Timecode also supports passing start timecodes formatted like HH:MM:SS.sss where SS.sss -is seconds and fractions of seconds: +Timecode also supports passing start timecodes formatted like HH:MM:SS.sss +where SS.sss is seconds and fractions of seconds: ```py tc8 = Timecode(25, '00:00:00.040') @@ -87,10 +89,11 @@ assert repr(tc9) == '19:23:14.958' Fraction of seconds is useful when working with tools like FFmpeg. -The SMPTE standard limits the timecode with 24 hours. Even though, Timecode instance -will show the current timecode inline with the SMPTE standard, it will keep counting the -total frames without clipping it. +The SMPTE standard limits the timecode with 24 hours. Even though, Timecode +instance will show the current timecode inline with the SMPTE standard, it will +keep counting the total frames without clipping it. -Please report any bugs to the [GitHub](https://github.com/eoyilmaz/timecode) page. +Please report any bugs to the [GitHub](https://github.com/eoyilmaz/timecode) +page. Copyright 2014 Joshua Banton and PyTimeCode developers. diff --git a/tests/test_timecode.py b/tests/test_timecode.py index d1a1160..f6ccbfe 100644 --- a/tests/test_timecode.py +++ b/tests/test_timecode.py @@ -16,6 +16,11 @@ [["50", "00:00:00:00"], {}], [["59.94", "00:00:00;00"], {}], [["60", "00:00:00:00"], {}], + [["72", "00:00:00:00"], {}], + [["96", "00:00:00:00"], {}], + [["100", "00:00:00:00"], {}], + [["119.88", "00:00:00;00"], {}], + [["120", "00:00:00:00"], {}], [["ms", "03:36:09.230"], {}], [["24"], {"start_timecode": None, "frames": 12000}], [["23.976"], {}], @@ -44,12 +49,22 @@ [["30000/1001", "00:00:00;00"], {}], [["60000/1000", "00:00:00:00"], {}], [["60000/1001", "00:00:00;00"], {}], + [["72000/1000", "00:00:00:00"], {}], + [["96000/1000", "00:00:00:00"], {}], + [["100000/1000", "00:00:00:00"], {}], + [["120000/1000", "00:00:00:00"], {}], + [["120000/1001", "00:00:00;00"], {}], [[(24000, 1000), "00:00:00:00"], {}], [[(24000, 1001), "00:00:00;00"], {}], [[(30000, 1000), "00:00:00:00"], {}], [[(30000, 1001), "00:00:00;00"], {}], [[(60000, 1000), "00:00:00:00"], {}], [[(60000, 1001), "00:00:00;00"], {}], + [[(72000, 1000), "00:00:00:00"], {}], + [[(96000, 1000), "00:00:00:00"], {}], + [[(100000, 1000), "00:00:00:00"], {}], + [[(120000, 1000), "00:00:00:00"], {}], + [[(120000, 1001), "00:00:00;00"], {}], [[12], {"frames": 12000}], [[24], {"frames": 12000}], [[23.976, "00:00:00:00"], {}], @@ -99,6 +114,14 @@ def test_2398_vs_23976(): [["60", "00:00:09:00"], {}, "00:00:09:00", True], [["59.94", "00:00:20;00"], {}, "00:00:20;00", True], [["59.94", "00:00:20;00"], {}, "00:00:20:00", False], + [["72", "00:00:09:00"], {}, "00:00:09:00", True], + [["96", "00:00:09:00"], {}, "00:00:09:00", True], + [["100", "00:00:09:00"], {}, "00:00:09:00", True], + [["120", "00:00:09:00"], {}, "00:00:09:00", True], + [["119.88", "00:00:20;00"], {}, "00:00:20;00", True], + [["119.88", "00:00:20;00"], {}, "00:00:20:00", False], + [["119.88", "01:30:45;100"], {}, "01:30:45;100", True], + [["119.88", "00:09:00:00"], {"force_non_drop_frame": True}, "00:09:00:00", True], [["ms", "00:00:00.900"], {}, "00:00:00.900", True], [["ms", "00:00:00.900"], {}, "00:00:00:900", False], [["24"], {"frames": 49}, "00:00:02:00", True], @@ -137,11 +160,17 @@ def test_repr_overload_2(): [["59.94", "03:36:09;23"], {}, None, 777384, None], [["60", "03:36:09:23"], {}, None, 778164, None], [["59.94", "03:36:09;23"], {}, None, 777384, None], + [["72", "03:36:09:23"], {}, None, 933792, None], + [["96", "03:36:09:23"], {}, None, 1245048, None], + [["100", "03:36:09:23"], {}, None, 1296924, None], + [["120", "03:36:09:23"], {}, None, 1556304, None], + [["119.88", "03:36:09;23"], {}, None, 1554744, None], [["23.98", "03:36:09:23"], {}, None, 311280, None], [["24", "03:36:09:23"], {}, None, 311280, None], [["24"], {"frames": 12000}, "00:08:19:23", None, None], [["25", 421729315], {}, "19:23:14:23", None, None], [["29.97", 421729315], {}, "19:23:14;23", None, True], + [["119.88"], {"frames": 1554744}, "03:36:09;23", None, True], [["23.98"], {"frames": 311280 * 720}, "01:59:59:23", None, None], [["23.98"], {"frames": 172800}, "01:59:59:23", None, None], ] @@ -179,6 +208,11 @@ def test_start_seconds_argument_is_zero(): [["59.94", "03:36:09;23"], {}, 3, 36, 9, 23, None], [["60", "03:36:09:23"], {}, 3, 36, 9, 23, None], [["59.94", "03:36:09;23"], {}, 3, 36, 9, 23, None], + [["72", "03:36:09:23"], {}, 3, 36, 9, 23, None], + [["96", "03:36:09:23"], {}, 3, 36, 9, 23, None], + [["100", "03:36:09:23"], {}, 3, 36, 9, 23, None], + [["120", "03:36:09:23"], {}, 3, 36, 9, 23, None], + [["119.88", "03:36:09;23"], {}, 3, 36, 9, 23, None], [["23.98", "03:36:09:23"], {}, 3, 36, 9, 23, None], [["24", "03:36:09:23"], {}, 3, 36, 9, 23, None], [["ms", "03:36:09.230"], {}, 3, 36, 9, 230, None], @@ -213,10 +247,25 @@ def test_timecode_properties_test(args, kwargs, hrs, mins, secs, frs, str_repr): [["59.94", "13:36:59;59"], {}, None, None, "13:37:00;04"], [["59.94", "13:39:59;59"], {}, None, None, "13:40:00;00"], [["29.97", "13:39:59;29"], {}, None, None, "13:40:00;00"], + [["89.91", "00:00:00;00"], {}, 1, None, None], + [["89.91", "00:00:00;89"], {}, 90, None, None], + [["89.91", "00:00:01;00"], {}, 91, None, None], + [["89.91", "00:01:00;00"], {}, 5395, "00:00:59;84", None], + [["89.91", "13:36:59;89"], {}, None, None, "13:37:00;06"], + [["89.91", "13:39:59;89"], {}, None, None, "13:40:00;00"], + [["119.88", "00:00:00;00"], {}, 1, None, None], + [["119.88", "00:00:00;119"], {}, 120, None, None], + [["119.88", "00:00:01;00"], {}, 121, None, None], + [["119.88", "00:01:00;00"], {}, 7193, "00:00:59;112", None], + [["119.88", "23:59:59;119"], {}, 10357632, None, None], + [["119.88", "01:00:00;00"], {"force_non_drop_frame": True}, None, "01:00:00:00", None], + [["119.88", "01:00:00:00"], {"force_non_drop_frame": True}, None, "01:00:00:00", None], + [["119.88", "13:36:59;119"], {}, None, None, "13:37:00;08"], + [["119.88", "13:39:59;119"], {}, None, None, "13:40:00;00"], ] ) -def test_tc_to_frame_test_in_2997(args, kwargs, frames, str_repr, tc_next): - """timecode to frame conversion is ok in 2997.""" +def test_ntsc_drop_frame_conversion(args, kwargs, frames, str_repr, tc_next): + """Test timecode to frame conversion for NTSC drop frame rates (29.97, 59.94, 119.88).""" tc = Timecode(*args, **kwargs) if frames is not None: assert frames == tc._frames @@ -226,25 +275,22 @@ def test_tc_to_frame_test_in_2997(args, kwargs, frames, str_repr, tc_next): assert tc_next == tc.next().__str__() -def test_setting_frame_rate_to_2997_forces_drop_frame(): - """Setting the frame rate to 29.97 forces the dropframe to True.""" - tc = Timecode("29.97") - assert tc.drop_frame - - -def test_setting_frame_rate_to_5994_forces_drop_frame(): - """Setting the frame rate to 59.94 forces the dropframe to True.""" - tc = Timecode("59.94") +@pytest.mark.parametrize( + "framerate", ["29.97", "59.94", "89.91", "119.88"] +) +def test_setting_ntsc_frame_rate_forces_drop_frame(framerate): + """Setting NTSC drop frame rates forces the dropframe to True.""" + tc = Timecode(framerate) assert tc.drop_frame -def test_setting_frame_rate_to_ms_forces_drop_frame(): +def test_setting_framerate_to_ms_enables_ms_frame(): """Setting the frame rate to ms forces the ms_frame to True.""" tc = Timecode("ms") assert tc.ms_frame -def test_setting_frame_rate_to_1000_forces_drop_frame(): +def test_setting_framerate_to_1000_enables_ms_frame(): """Setting the frame rate to 1000 forces the ms_frame to True.""" tc = Timecode("1000") assert tc.ms_frame @@ -265,6 +311,11 @@ def test_framerate_argument_is_frames(): [["59.94", "03:36:09;23"], {}, "03:36:09;23", 60, "03:36:10;23", 777444], [["60", "03:36:09:23"], {}, "03:36:09:23", 60, "03:36:10:23", 778224], [["59.94", "03:36:09:23"], {}, "03:36:09;23", 60, "03:36:10:23", 777444], + [["72", "03:36:09:23"], {}, "03:36:09:23", 120, "03:36:10:71", 933912], + [["96", "03:36:09:23"], {}, "03:36:09:23", 120, "03:36:10:47", 1245168], + [["100", "03:36:09:23"], {}, "03:36:09:23", 120, "03:36:10:43", 1297044], + [["120", "03:36:09:23"], {}, "03:36:09:23", 120, "03:36:10:23", 1556424], + [["119.88", "03:36:09;23"], {}, "03:36:09;23", 120, "03:36:10;23", 1554864], [["23.98", "03:36:09:23"], {}, "03:36:09:23", 60, "03:36:12:11", 311340], [["24", "03:36:09:23"], {}, "03:36:09:23", 60, "03:36:12:11", 311340], [["ms", "03:36:09.230"], {}, "03:36:09.230", 60, "03:36:09.290", 12969291], @@ -293,9 +344,13 @@ def test_iteration(args, kwargs, str_repr, next_range, last_tc_str_repr, frames) [["59.94", "03:36:09;23"], {}, ["59.94", "00:00:29;23"], {}, 1764, 1764, "03:36:38;47", "03:36:38;47", 779148, 779148], [["60", "03:36:09:23"], {}, ["60", "00:00:29:23"], {}, 1764, 1764, "03:36:38:47", "03:36:38:47", 779928, 779928], [["59.94", "03:36:09;23"], {}, ["59.94", "00:00:29;23"], {}, 1764, 1764, "03:36:38;47", "03:36:38;47", 779148, 779148], + [["72", "03:36:09:23"], {}, ["72", "00:00:29:23"], {}, 2112, 2112, "03:36:38:47", "03:36:38:47", 935904, 935904], + [["96", "03:36:09:23"], {}, ["96", "00:00:29:23"], {}, 2808, 2808, "03:36:38:47", "03:36:38:47", 1247856, 1247856], + [["100", "03:36:09:23"], {}, ["100", "00:00:29:23"], {}, 2924, 2924, "03:36:38:47", "03:36:38:47", 1299848, 1299848], + [["120", "03:36:09:23"], {}, ["120", "00:00:29:23"], {}, 3504, 3504, "03:36:38:47", "03:36:38:47", 1559808, 1559808], + [["119.88", "03:36:09;23"],{}, ["119.88", "00:00:29;23"],{}, 3504, 3504, "03:36:38;47", "03:36:38;47", 1558248, 1558248], [["23.98", "03:36:09:23"], {}, ["23.98", "00:00:29:23"], {}, 720, 720, "03:36:39:23", "03:36:39:23", 312000, 312000], [["ms", "03:36:09.230"], {}, ["ms", "01:06:09.230"], {}, 3969231, 720, "04:42:18.461", "03:36:09.950", 16938462, 12969951], - [["ms", "03:36:09.230"], {}, ["ms", "01:06:09.230"], {}, 3969231, 720, "04:42:18.461", "03:36:09.950", 16938462, 12969951], [["24"], {"frames": 12000}, ["24"], {"frames": 485}, 485, 719, "00:08:40:04", "00:08:49:22", 12485, 12719], [["59.94", "04:20:13;21"], {}, ["59.94", "23:59:59;59"], {}, 5178816, 0, "04:20:13;21", "04:20:13;21", 6114682, 935866], ] @@ -321,6 +376,11 @@ def test_op_overloads_add(args1, kwargs1, args2, kwargs2, custom_offset1, custom [["59.94", "03:36:09;23"], {}, ["59.94", "00:00:29;23"], {}, 1764, 1764, "03:35:39;55", "03:35:39;55", 775620, 775620], [["60", "03:36:09:23"], {}, ["60", "00:00:29:23"], {}, 1764, 1764, "03:35:39:59", "03:35:39:59", 776400, 776400], [["59.94", "03:36:09;23"], {}, ["59.94", "00:00:29;23"], {}, 1764, 1764, "03:35:39;55", "03:35:39;55", 775620, 775620], + [["72", "03:36:09:23"], {}, ["72", "00:00:29:23"], {}, 2112, 2112, "03:35:39:71", "03:35:39:71", 931680, 931680], + [["96", "03:36:09:23"], {}, ["96", "00:00:29:23"], {}, 2808, 2808, "03:35:39:95", "03:35:39:95", 1242240, 1242240], + [["100", "03:36:09:23"], {}, ["100", "00:00:29:23"], {}, 2924, 2924, "03:35:39:99", "03:35:39:99", 1294000, 1294000], + [["120", "03:36:09:23"], {}, ["120", "00:00:29:23"], {}, 3504, 3504, "03:35:39:119", "03:35:39:119", 1552800, 1552800], + [["119.88", "03:36:09;23"], {}, ["119.88", "00:00:29;23"], {}, 3504, 3504, "03:35:39;111", "03:35:39;111", 1551240, 1551240], [["23.98", "03:36:09:23"], {}, ["23.98", "00:00:29:23"], {}, 720, 720, "03:35:39:23", "03:35:39:23", 310560, 310560], [["23.98", "03:36:09:23"], {}, ["23.98", "00:00:29:23"], {}, 720, 720, "03:35:39:23", "03:35:39:23", 310560, 310560], [["ms", "03:36:09.230"], {}, ["ms", "01:06:09.230"], {}, 3969231, 3969231, "02:29:59.999", "02:29:59.999", 9000000, 9000000], @@ -348,9 +408,13 @@ def test_op_overloads_subtract(args1, kwargs1, args2, kwargs2, custom_offset1, c [["59.94", "03:36:09;23"], {}, ["59.94", "00:00:29;23"], {}, 1764, 1764, "18:59:27;35", "18:59:27;35", 1371305376, 1371305376], [["60", "03:36:09:23"], {}, ["60", "00:00:29:23"], {}, 1764, 1764, "19:00:21:35", "19:00:21:35", 1372681296, 1372681296], [["59.94", "03:36:09;23"], {}, ["59.94", "00:00:29;23"], {}, 1764, 1764, "18:59:27;35", "18:59:27;35", 1371305376, 1371305376], + [["72", "03:36:09:23"], {}, ["72", "00:00:29:23"], {}, 2112, 2112, "00:40:31:71", "00:40:31:71", 1972168704, 1972168704], + [["96", "03:36:09:23"], {}, ["96", "00:00:29:23"], {}, 2808, 2808, "12:00:53:95", "12:00:53:95", 3496094784, 3496094784], + [["100", "03:36:09:23"], {}, ["100", "00:00:29:23"], {}, 2924, 2924, "21:54:17:75", "21:54:17:75", 3792205776, 3792205776], + [["120", "03:36:09:23"], {}, ["120", "00:00:29:23"], {}, 3504, 3504, "23:21:16:95", "23:21:16:95", 5453289216, 5453289216], + [["119.88", "03:36:09;23"], {}, ["119.88", "00:00:29;23"], {}, 3504, 3504, "23:19:28;95", "23:19:28;95", 5447822976, 5447822976], [["ms", "03:36:09.230"], {}, ["ms", "01:06:09.230"], {}, 3969231, 3969231, "17:22:11.360", "17:22:11.360", 51477873731361, 51477873731361], [["24"], {"frames": 12000}, ["24"], {"frames": 485}, 485, 485, "19:21:39:23", "19:21:39:23", 5820000, 5820000], - [["24"], {"frames": 12000}, ["24"], {"frames": 485}, 485, 485, "19:21:39:23", "19:21:39:23", 5820000, 5820000], ] ) def test_op_overloads_mult(args1, kwargs1, args2, kwargs2, custom_offset1, custom_offset2, str_repr1, str_repr2, frames1, frames2): @@ -412,7 +476,7 @@ def test_add_with_two_different_frame_rates(): [["24", "00:00:01:00"], {}, lambda x, y: x / y, 32.4], ] ) -def test_add_with_non_suitable_class_instance(args, kwargs, func, tc2): +def test_arithmetic_with_unsupported_type_raises_error(args, kwargs, func, tc2): """TimecodeError is raised if the other class is not suitable for the operation.""" tc1 = Timecode(*args, **kwargs) with pytest.raises(TimecodeError) as cm: @@ -448,6 +512,12 @@ def test_div_method_working_properly_2(): [["50", "00:01:00:00"], 3001, 3000], [["59.94", "00:01:00;00"], 3597, 3596], [["60", "00:01:00:00"], 3601, 3600], + [["72", "00:01:00:00"], 4321, 4320], + [["89.91", "00:01:00;00"], 5395, 5394], + [["96", "00:01:00:00"], 5761, 5760], + [["100", "00:01:00:00"], 6001, 6000], + [["120", "00:01:00:00"], 7201, 7200], + [["119.88", "00:01:00;00"], 7193, 7192], ] ) def test_frame_number_attribute_value_is_correctly_calculated(args, frames, frame_number): @@ -580,6 +650,16 @@ def test_framerate_can_be_changed(): [["60000/1000", "00:00:00:00"], {}, "60", 60], [["60000/1001", "00:00:00;00"], {}, "59.94", 60], [[(60000, 1001), "00:00:00;00"], {}, "59.94", 60], + [["72000/1000", "00:00:00:00"], {}, "72", 72], + [[(72000, 1000), "00:00:00:00"], {}, "72", 72], + [["96000/1000", "00:00:00:00"], {}, "96", 96], + [[(96000, 1000), "00:00:00:00"], {}, "96", 96], + [["100000/1000", "00:00:00:00"], {}, "100", 100], + [[(100000, 1000), "00:00:00:00"], {}, "100", 100], + [["120000/1000", "00:00:00:00"], {}, "120", 120], + [["120000/1001", "00:00:00;00"], {}, "119.88", 120], + [[(120000, 1000), "00:00:00:00"], {}, "120", 120], + [[(120000, 1001), "00:00:00;00"], {}, "119.88", 120], ] ) def test_rational_framerate_conversion(args, kwargs, frame_rate, int_framerate): @@ -763,7 +843,7 @@ def test_le_overload(): assert tc5 > tc4 -def test_gt_overload_b(): +def test_lt_overload(): tc1 = Timecode(24, "00:00:00:00") tc2 = Timecode(24, "00:00:00:00") tc3 = Timecode(24, "00:00:00:01") @@ -1010,8 +1090,71 @@ def test_rollover_for_23_98(): [["59.94"], {"frames": 5178817}, "00:00:00;00"], [["59.94"], {"frames": 5184000, "force_non_drop_frame": True}, "23:59:59:59"], [["59.94"], {"frames": 5184001, "force_non_drop_frame": True}, "00:00:00:00"], + [["72"], {"frames": 6220800}, "23:59:59:71"], + [["72"], {"frames": 6220801}, "00:00:00:00"], + [["89.91"], {"frames": 7768224}, "23:59:59;89"], + [["89.91"], {"frames": 7768225}, "00:00:00;00"], + [["96"], {"frames": 8294400}, "23:59:59:95"], + [["96"], {"frames": 8294401}, "00:00:00:00"], + [["100"], {"frames": 8640000}, "23:59:59:99"], + [["100"], {"frames": 8640001}, "00:00:00:00"], + [["120"], {"frames": 10368000}, "23:59:59:119"], + [["120"], {"frames": 10368001}, "00:00:00:00"], + [["119.88"], {"frames": 10357632}, "23:59:59;119"], + [["119.88"], {"frames": 10357633}, "00:00:00;00"], ] ) def test_rollover(args, kwargs, str_repr): tc = Timecode(*args, **kwargs) assert str_repr == tc.__str__() + + +@pytest.mark.parametrize( + "framerate,int_framerate,is_drop,one_minute_frames,expected_tc", [ + # Non-drop NTSC rates (multiples of 24000/1001) + ["47.952", 48, False, 2881, "00:01:00:00"], # 2 * 23.976 fps - HFR broadcast + ["71.928", 72, False, 4321, "00:01:00:00"], # 3 * 23.976 fps + ["95.904", 96, False, 5761, "00:01:00:00"], # 4 * 23.976 fps + # Drop frame NTSC rate (multiple of 30000/1001) + # For drop frame, test at 10-minute mark where frames aren't skipped + ["89.91", 90, True, 53947, "00:10:00;00"], # 3 * 29.97 fps - with drop frame + ] +) +def test_generalized_ntsc_rates(framerate, int_framerate, is_drop, one_minute_frames, expected_tc): + """Test generalized NTSC detection for HFR rates. + + Tests automatic NTSC detection for rates based on multiples of 24000/1001 or 30000/1001. + Drop frame should only apply to multiples of 30000/1001 (i.e., int_framerate % 30 == 0). + """ + # Test basic creation and NTSC detection + separator = ";" if is_drop else ":" + tc = Timecode(framerate, f"00:00:00{separator}00") + assert tc._ntsc_framerate is True + assert tc._int_framerate == int_framerate + assert tc.drop_frame is is_drop + assert tc.framerate == framerate + + # Test frame counting - one second should be int_framerate + 1 + tc2 = Timecode(framerate, f"00:00:01{separator}00") + assert tc2.frames == int_framerate + 1 + + # Test frame count displays correctly + tc3 = Timecode(framerate, frames=one_minute_frames) + assert str(tc3) == expected_tc + + +@pytest.mark.parametrize( + "rational_str,int_framerate,is_drop", [ + ["48000/1001", 48, False], # 47.952 fps + ["72000/1001", 72, False], # 71.928 fps + ["90000/1001", 90, True], # 89.91 fps - drop frame + ["96000/1001", 96, False], # 95.904 fps + ] +) +def test_generalized_ntsc_rational_formats(rational_str, int_framerate, is_drop): + """Test that rational format fractions work for new NTSC rates.""" + separator = ";" if is_drop else ":" + tc = Timecode(rational_str, f"00:00:00{separator}00") + assert tc._ntsc_framerate is True + assert tc._int_framerate == int_framerate + assert tc.drop_frame is is_drop diff --git a/timecode/__init__.py b/timecode/__init__.py index bd4e5f2..abcd9b6 100644 --- a/timecode/__init__.py +++ b/timecode/__init__.py @@ -68,6 +68,31 @@ class Timecode(object): default. """ + @staticmethod + def _is_ntsc_rate(fps: float) -> Tuple[bool, int]: + """Check if framerate is NTSC (multiple of 24000/1001 or 30000/1001). + + NTSC rates follow the pattern: nominal_rate * 1000/1001 + Examples: 23.976, 29.97, 47.952, 59.94, 71.928, 89.91, 95.904, 119.88 + + Args: + fps (float): The framerate to check. + + Returns: + tuple: (is_ntsc, int_framerate) where is_ntsc is True if this is an NTSC rate, + and int_framerate is the rounded integer framerate. + """ + # Calculate what the integer framerate would be if this is NTSC + int_fps = round(fps * 1001 / 1000) + + # Calculate what the NTSC rate would be for this integer framerate + expected_ntsc = int_fps * 1000 / 1001 + + # Check if the input matches expected NTSC rate (within tolerance) + is_ntsc = abs(fps - expected_ntsc) < 0.005 + + return is_ntsc, int_fps + def __init__( self, framerate: Union[str, int, float, Fraction], @@ -187,26 +212,31 @@ def framerate(self, framerate: Union[int, float, str, Tuple[int, int], Fraction] self._ntsc_framerate = False - # set the int_frame_rate - if framerate == "29.97": - self._int_framerate = 30 - self.drop_frame = not self.force_non_drop_frame - self._ntsc_framerate = True - elif framerate == "59.94": - self._int_framerate = 60 - self.drop_frame = not self.force_non_drop_frame - self._ntsc_framerate = True - elif any(map(lambda x: framerate.startswith(x), ["23.976", "23.98"])): # type: ignore - self._int_framerate = 24 - self._ntsc_framerate = True - elif framerate in ["ms", "1000"]: + # Handle special cases first + if framerate in ["ms", "1000"]: self._int_framerate = 1000 self.ms_frame = True framerate = 1000 elif framerate == "frames": self._int_framerate = 1 else: - self._int_framerate = int(float(framerate)) # type: ignore + # Try to detect NTSC rates + try: + fps = float(framerate) # type: ignore + is_ntsc, int_fps = self._is_ntsc_rate(fps) + + if is_ntsc: + self._ntsc_framerate = True + self._int_framerate = int_fps + # DF only for multiples of 30000/1001 (29.97, 59.94, etc.). + if int_fps % 30 == 0: + self.drop_frame = not self.force_non_drop_frame + else: + # Non-NTSC rate, use integer value + self._int_framerate = int(fps) + except (ValueError, TypeError): + # If conversion fails, fall back to direct integer conversion + self._int_framerate = int(float(framerate)) # type: ignore self._framerate = framerate # type: ignore @@ -267,7 +297,7 @@ def tc_to_frames(self, timecode: Union[str, "Timecode"]) -> int: if self.framerate != "frames": ffps = float(self.framerate) else: - ffps = float(self._int_framerate) + ffps = float(self._int_framerate) if self.drop_frame: # Number of drop frames is 6% of framerate rounded to nearest @@ -354,7 +384,7 @@ def frames_to_tc(self, frames: int, skip_rollover: bool = False) -> Tuple[int, i frs: Union[int, float] = frame_number % ifps if self.fraction_frame: - frs = round(frs / float(ifps), 3) + frs = round(frs / float(ifps), 3) secs = int((frame_number // ifps) % 60) mins = int(((frame_number // ifps) // 60) % 60) @@ -401,7 +431,7 @@ def to_systemtime(self, as_float: bool = False) -> Union[str, float]: # type:ig For NTSC rates, the video system time is not the wall-clock one. Args: - as_float (bool): Return the time as a float number of seconds. + as_float (bool): Return the time as a float number of seconds. Returns: str: The "system time" timestamp of the Timecode. @@ -443,7 +473,7 @@ def to_realtime(self, as_float: bool = False) -> Union[str, float]: # type:igno if self.ms_frame: return ts_float-(1e-3) if as_float else str(self) - # "int_framerate" frames is one second in NTSC time + # "int_framerate" frames is one second in NTSC time if self._ntsc_framerate: ts_float *= 1.001 if as_float: @@ -594,7 +624,7 @@ def __eq__(self, other: Union[int, str, "Timecode", object]) -> bool: return self.__eq__(new_tc) elif isinstance(other, int): return self.frames == other - else: + else: return False def __ge__(self, other: Union[int, str, "Timecode", object]) -> bool: