diff --git a/README.md b/README.md index eafd644..8ee53dd 100644 --- a/README.md +++ b/README.md @@ -23,12 +23,13 @@ with an integer value or with a timecode is possible. Math operations between timecodes with different frame rates are supported. So: ```py +from fractions import Fraction from timecode import Timecode tc1 = Timecode('29.97', '00:00:00;00') tc2 = Timecode(24, '00:00:00:10') tc3 = tc1 + tc2 -assert tc3.framerate == '29.97' +assert tc3.framerate == Fraction(30000, 1001) assert tc3.frames == 12 assert tc3 == '00:00:00:11' ``` @@ -60,8 +61,9 @@ are non drop frame. The timecode library supports fractional frame rates passed as a string: ```py +from fractions import Fraction tc5 = Timecode('30000/1001', '00:00:00;00') -assert tc5.framerate == '29.97' +assert tc5.framerate == Fraction(30000, 1001) ``` You may also pass a big "Binary Coded Decimal" integer as start timecode: diff --git a/src/timecode/timecode.py b/src/timecode/timecode.py index 6a0e2b3..d1c0a43 100644 --- a/src/timecode/timecode.py +++ b/src/timecode/timecode.py @@ -5,6 +5,7 @@ import math from contextlib import suppress +from fractions import Fraction from typing import TYPE_CHECKING, overload with suppress(ImportError): @@ -14,13 +15,39 @@ if TYPE_CHECKING: import sys from collections.abc import Iterator - from fractions import Fraction if sys.version_info >= (3, 11): from typing import Self else: from typing_extensions import Self +# Pre-computed table for common string framerate inputs. Avoids Fraction(str) +# parsing and float conversion on every construction for the typical cases. +# Each entry: (canonical_fraction, is_ntsc, int_framerate) +_FRAMERATE_CACHE: dict[str, tuple[Fraction, bool, int]] = { + "23.976": (Fraction(24000, 1001), True, 24), + "23.98": (Fraction(24000, 1001), True, 24), + "24": (Fraction(24, 1), False, 24), + "25": (Fraction(25, 1), False, 25), + "29.97": (Fraction(30000, 1001), True, 30), + "30": (Fraction(30, 1), False, 30), + "47.952": (Fraction(48000, 1001), True, 48), + "48": (Fraction(48, 1), False, 48), + "50": (Fraction(50, 1), False, 50), + "59.94": (Fraction(60000, 1001), True, 60), + "60": (Fraction(60, 1), False, 60), + "71.928": (Fraction(72000, 1001), True, 72), + "72": (Fraction(72, 1), False, 72), + "89.91": (Fraction(90000, 1001), True, 90), + "90": (Fraction(90, 1), False, 90), + "95.904": (Fraction(96000, 1001), True, 96), + "96": (Fraction(96, 1), False, 96), + "100": (Fraction(100, 1), False, 100), + "119.88": (Fraction(120000, 1001), True, 120), + "120": (Fraction(120, 1), False, 120), +} + + class Timecode: """The main timecode class. @@ -79,6 +106,24 @@ def _is_ntsc_rate(fps: float) -> tuple[bool, int]: return is_ntsc, int_fps + @staticmethod + def _classify_fps(fps: Fraction) -> tuple[Fraction, bool, int]: + """Classify a Fraction framerate and return its canonical form. + + Returns: + tuple: (canonical_fraction, is_ntsc, int_framerate) + """ + if fps == 1000: + return fps, False, 1000 + fps_float = float(fps) + if fps_float >= 2: + is_ntsc, int_fps = Timecode._is_ntsc_rate(fps_float) + else: + is_ntsc, int_fps = False, round(fps_float) + if is_ntsc: + return Fraction(int_fps * 1000, 1001), True, int_fps + return fps, False, round(fps_float) + def __init__( self, framerate: str | float | Fraction, @@ -144,13 +189,13 @@ def frames(self, frames: int) -> None: self._frames = frames @property - def framerate(self) -> str: - """Return the _framerate attribute. + def framerate(self) -> Fraction: + """Return the framerate as a Fraction. Returns: - str: The frame rate of this Timecode instance. + Fraction: The frame rate of this Timecode instance. """ - return self._framerate # type: ignore + return self._framerate @framerate.setter def framerate(self, framerate: float | str | tuple[int, int] | Fraction) -> None: @@ -167,61 +212,33 @@ def framerate(self, framerate: float | str | tuple[int, int] | Fraction) -> None "frames" will result a Timecode with 1 FPS. tuple: The tuple should be in (nominator, denominator) format in which the frame rate is kept as a fraction. - Fraction: If the current version of Python supports (which it should) - then Fraction is also accepted. - """ - # Convert rational frame rate to float, defaults to None if not Fraction-like - numerator = getattr(framerate, "numerator", None) - denominator = getattr(framerate, "denominator", None) - - try: - if "/" in framerate: # type: ignore - numerator, denominator = framerate.split("/") # type: ignore - except TypeError: - # not a string - pass - - if isinstance(framerate, tuple): - numerator, denominator = framerate - - if numerator and denominator: - framerate = round(float(numerator) / float(denominator), 2) - if framerate.is_integer(): - framerate = int(framerate) - - # check if number is passed and if so convert it to a string - if isinstance(framerate, (int, float)): - framerate = str(framerate) - - self._ntsc_framerate = False - - # 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 + Fraction: Also accepted directly. + """ + # Fast path: common string rates are pre-computed at module load. + if isinstance(framerate, str) and (cached := _FRAMERATE_CACHE.get(framerate)): + new_fps, is_ntsc, int_fps = cached + else: + if isinstance(framerate, str): + if framerate in ("ms", "1000"): + framerate = 1000 + elif framerate == "frames": + framerate = 1 + if isinstance(framerate, tuple): + raw = Fraction(*map(int, framerate)) + else: + raw = Fraction(framerate) + if raw <= 0: + raise ValueError(f"Frame rate must be positive, got {framerate!r}") + new_fps, is_ntsc, int_fps = self._classify_fps(raw) + + self._framerate = new_fps + self._ntsc_framerate = is_ntsc + self._int_framerate = int_fps + self.ms_frame = (new_fps == 1000) + if is_ntsc and int_fps % 30 == 0: + self.drop_frame = not self.force_non_drop_frame else: - # 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 + self.drop_frame = False def set_fractional(self, state: bool) -> None: """Set if the Timecode is to be represented with fractional seconds. @@ -277,11 +294,7 @@ def tc_to_frames(self, timecode: str | Timecode) -> int: if self.drop_frame: timecode = ";".join(timecode.rsplit(":", 1)) - ffps = ( - float(self.framerate) - if self.framerate != "frames" - else float(self._int_framerate) - ) + ffps = float(self._framerate) # Number of drop frames is 6% of framerate rounded to nearest integer drop_frames = round(ffps * 0.066666) if self.drop_frame else 0 @@ -329,23 +342,26 @@ def frames_to_tc( tuple: A tuple containing the hours, minutes, seconds and frames """ if self.drop_frame: - # Number of frames to drop on the minute marks is the nearest - # integer to 6% of the framerate - ffps = float(self.framerate) - drop_frames = round(ffps * 0.066666) + # Drop frame only applies to multiples of 30000/1001; each 30fps + # unit drops 2 frames per minute. + drop_frames = self._int_framerate * 2 // 30 + frames_per_minute = self._int_framerate * 60 - drop_frames + frames_per_10_minutes = self._int_framerate * 600 - drop_frames * 9 + elif self._ntsc_framerate: + # Forced NDF on an NTSC rate: keep _int_framerate grid (30, 60, ...) + drop_frames = 0 + frames_per_minute = self._int_framerate * 60 + frames_per_10_minutes = self._int_framerate * 600 else: - ffps = float(self._int_framerate) + # Standard or custom rate: use the stored Fraction directly so that + # non-integer rates (e.g. Fraction(47, 2)) get correct period lengths. drop_frames = 0 + frames_per_minute = round(self._framerate * 60) + frames_per_10_minutes = round(self._framerate * 600) - # Number of frames per ten minutes - frames_per_10_minutes = round(ffps * 60 * 10) - - # Number of frames in a day - timecode rolls over after 24 hours - frames_per_24_hours = round(ffps * 60 * 60 * 24) - - # Number of frames per minute is the round of the framerate * 60 minus - # the number of dropped frames - frames_per_minute = int(round(ffps) * 60) - drop_frames + # Number of frames in a day - timecode rolls over after 24 hours. + # For drop frame, derived from frames_per_10_minutes to stay exact. + frames_per_24_hours = frames_per_10_minutes * 144 frame_number = frames - 1 @@ -422,10 +438,7 @@ def to_systemtime(self, as_float: bool = False) -> str | float: # type:ignore return self.float - (1e-3) if as_float else str(self) hh, mm, ss, ff = self.frames_to_tc(self.frames + 1, skip_rollover=True) - framerate = ( - float(self.framerate) if self._ntsc_framerate else self._int_framerate - ) - ms = ff / framerate + ms = ff / self._int_framerate if as_float: return hh * 3600 + mm * 60 + ss + ms return f"{hh:02d}:{mm:02d}:{ss:02d}.{round(ms * 1000):03d}" diff --git a/tests/test_timecode.py b/tests/test_timecode.py index 99efb52..af9ce4c 100644 --- a/tests/test_timecode.py +++ b/tests/test_timecode.py @@ -1,4 +1,6 @@ #!-*- coding: utf-8 -*- +from fractions import Fraction + import pytest from timecode import Timecode, TimecodeError @@ -335,7 +337,7 @@ def test_setting_framerate_to_1000_enables_ms_frame(): def test_framerate_argument_is_frames(): """Setting the framerate arg to 'frames' will set the integer frame rate to 1.""" tc = Timecode("frames") - assert tc.framerate == "frames" + assert tc.framerate == 1 assert tc._int_framerate == 1 @@ -981,7 +983,7 @@ def test_op_overloads_mult_1(): tc1 = Timecode("23.98", "03:36:09:23") tc2 = Timecode("23.98", "00:00:29:23") tc3 = tc1 * tc2 - assert tc3.framerate == "23.98" + assert tc3.framerate == Fraction(24000, 1001) def test_op_overloads_mult_2(): @@ -1008,7 +1010,7 @@ def test_add_with_two_different_frame_rates(): tc1 = Timecode("29.97", "00:00:00;00") tc2 = Timecode("24", "00:00:00:10") tc3 = tc1 + tc2 - assert "29.97" == tc3.framerate + assert Fraction(30000, 1001) == tc3.framerate assert 12 == tc3._frames assert tc3 == "00:00:00;11" @@ -1204,23 +1206,23 @@ def test_framerate_can_be_changed(): @pytest.mark.parametrize( "args,kwargs,frame_rate,int_framerate", [ - [["24000/1000", "00:00:00:00"], {}, "24", 24], - [["24000/1001", "00:00:00;00"], {}, "23.98", 24], - [["30000/1000", "00:00:00:00"], {}, "30", 30], - [["30000/1001", "00:00:00;00"], {}, "29.97", 30], - [["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], + [["24000/1000", "00:00:00:00"], {}, Fraction(24, 1), 24], + [["24000/1001", "00:00:00;00"], {}, Fraction(24000, 1001), 24], + [["30000/1000", "00:00:00:00"], {}, Fraction(30, 1), 30], + [["30000/1001", "00:00:00;00"], {}, Fraction(30000, 1001), 30], + [["60000/1000", "00:00:00:00"], {}, Fraction(60, 1), 60], + [["60000/1001", "00:00:00;00"], {}, Fraction(60000, 1001), 60], + [[(60000, 1001), "00:00:00;00"], {}, Fraction(60000, 1001), 60], + [["72000/1000", "00:00:00:00"], {}, Fraction(72, 1), 72], + [[(72000, 1000), "00:00:00:00"], {}, Fraction(72, 1), 72], + [["96000/1000", "00:00:00:00"], {}, Fraction(96, 1), 96], + [[(96000, 1000), "00:00:00:00"], {}, Fraction(96, 1), 96], + [["100000/1000", "00:00:00:00"], {}, Fraction(100, 1), 100], + [[(100000, 1000), "00:00:00:00"], {}, Fraction(100, 1), 100], + [["120000/1000", "00:00:00:00"], {}, Fraction(120, 1), 120], + [["120000/1001", "00:00:00;00"], {}, Fraction(120000, 1001), 120], + [[(120000, 1000), "00:00:00:00"], {}, Fraction(120, 1), 120], + [[(120000, 1001), "00:00:00;00"], {}, Fraction(120000, 1001), 120], ], ) def test_rational_framerate_conversion(args, kwargs, frame_rate, int_framerate): @@ -1737,7 +1739,7 @@ def test_generalized_ntsc_rates( assert tc._ntsc_framerate is True assert tc._int_framerate == int_framerate assert tc.drop_frame is is_drop - assert tc.framerate == framerate + assert tc.framerate == Fraction(int_framerate * 1000, 1001) # Test frame counting - one second should be int_framerate + 1 tc2 = Timecode(framerate, f"00:00:01{separator}00")