From 3e6bbdc0b1aa216c18d1513303ae0459e03c1a39 Mon Sep 17 00:00:00 2001 From: Nick Walker Date: Fri, 26 Jun 2026 18:38:23 -0600 Subject: [PATCH 1/2] Store framerate internally as Fraction, expose as Fraction property MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Previously the framerate setter normalized all inputs to a rounded string (e.g. Fraction(24000, 1001) → "23.98"), so tc.framerate always returned str. The setter now stores a canonical Fraction and the property returns it directly. NTSC rates are canonicalized to their exact fraction (24000/1001, 30000/1001, etc.) regardless of how they were specified. The "frames" alias (1 fps) and "ms" alias (1000 fps) are still accepted as input. As a consequence, frames_to_tc's drop-frame period calculations are switched from float-based rounding to exact integer arithmetic using _int_framerate, which is more correct and avoids precision sensitivity to the exact float representation of the framerate. Co-authored-by: cubicibo <55701024+cubicibo@users.noreply.github.com> --- src/timecode/timecode.py | 101 ++++++++++++++++----------------------- tests/test_timecode.py | 44 +++++++++-------- 2 files changed, 63 insertions(+), 82 deletions(-) diff --git a/src/timecode/timecode.py b/src/timecode/timecode.py index 6a0e2b3..05eff3c 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,7 +15,6 @@ 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: @@ -144,13 +144,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 +167,48 @@ 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. + Fraction: Also accepted directly. """ - # 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 + # Normalize string aliases before conversion + if isinstance(framerate, str): + if framerate in ("ms", "1000"): + framerate = 1000 + elif framerate == "frames": + framerate = 1 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) + new_fps = Fraction(*map(int, framerate)) + else: + new_fps = Fraction(framerate) - # check if number is passed and if so convert it to a string - if isinstance(framerate, (int, float)): - framerate = str(framerate) + if new_fps <= 0: + raise ValueError(f"Frame rate must be positive, got {framerate!r}") self._ntsc_framerate = False + self.ms_frame = (new_fps == 1000) - # Handle special cases first - if framerate in ["ms", "1000"]: + if self.ms_frame: self._int_framerate = 1000 - self.ms_frame = True - framerate = 1000 - elif framerate == "frames": - self._int_framerate = 1 else: - # Try to detect NTSC rates try: - fps = float(framerate) # type: ignore - is_ntsc, int_fps = self._is_ntsc_rate(fps) - + # No NTSC rate exists below 24000/1001 (~23.98); guard prevents + # 1fps ("frames" mode) from being misidentified as NTSC. + fps_float = float(new_fps) + is_ntsc, int_fps = self._is_ntsc_rate(fps_float) if fps_float >= 2 else (False, round(fps_float)) if is_ntsc: self._ntsc_framerate = True self._int_framerate = int_fps - # DF only for multiples of 30000/1001 (29.97, 59.94, etc.). + # Canonicalize to exact NTSC fraction (e.g. "23.98" → 24000/1001) + new_fps = Fraction(int_fps * 1000, 1001) + # 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) + self._int_framerate = round(float(new_fps)) except (ValueError, TypeError): - # If conversion fails, fall back to direct integer conversion - self._int_framerate = int(float(framerate)) # type: ignore + self._int_framerate = round(float(new_fps)) - self._framerate = framerate # type: ignore + self._framerate = new_fps def set_fractional(self, state: bool) -> None: """Set if the Timecode is to be represented with fractional seconds. @@ -277,11 +264,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 +312,19 @@ 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 else: - ffps = float(self._int_framerate) drop_frames = 0 + frames_per_minute = self._int_framerate * 60 + frames_per_10_minutes = self._int_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 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") From 688e9d49469b78a0ec0134b9b163decce6750c0f Mon Sep 17 00:00:00 2001 From: Nick Walker Date: Fri, 26 Jun 2026 18:56:08 -0600 Subject: [PATCH 2/2] Use cache to accelerate construction for common framerates Converting to Fraction is about 2x slower than the original code path. Loading from the cache is modestly faster, and should handle the vast majority of usage --- README.md | 6 +- src/timecode/timecode.py | 116 +++++++++++++++++++++++++-------------- 2 files changed, 79 insertions(+), 43 deletions(-) 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 05eff3c..d1c0a43 100644 --- a/src/timecode/timecode.py +++ b/src/timecode/timecode.py @@ -21,6 +21,33 @@ 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, @@ -169,46 +214,31 @@ def framerate(self, framerate: float | str | tuple[int, int] | Fraction) -> None the frame rate is kept as a fraction. Fraction: Also accepted directly. """ - # Normalize string aliases before conversion - if isinstance(framerate, str): - if framerate in ("ms", "1000"): - framerate = 1000 - elif framerate == "frames": - framerate = 1 - - if isinstance(framerate, tuple): - new_fps = Fraction(*map(int, framerate)) + # 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: - new_fps = Fraction(framerate) - - if new_fps <= 0: - raise ValueError(f"Frame rate must be positive, got {framerate!r}") + 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._ntsc_framerate = False + self._framerate = new_fps + self._ntsc_framerate = is_ntsc + self._int_framerate = int_fps self.ms_frame = (new_fps == 1000) - - if self.ms_frame: - self._int_framerate = 1000 + if is_ntsc and int_fps % 30 == 0: + self.drop_frame = not self.force_non_drop_frame else: - try: - # No NTSC rate exists below 24000/1001 (~23.98); guard prevents - # 1fps ("frames" mode) from being misidentified as NTSC. - fps_float = float(new_fps) - is_ntsc, int_fps = self._is_ntsc_rate(fps_float) if fps_float >= 2 else (False, round(fps_float)) - if is_ntsc: - self._ntsc_framerate = True - self._int_framerate = int_fps - # Canonicalize to exact NTSC fraction (e.g. "23.98" → 24000/1001) - new_fps = Fraction(int_fps * 1000, 1001) - # 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: - self._int_framerate = round(float(new_fps)) - except (ValueError, TypeError): - self._int_framerate = round(float(new_fps)) - - self._framerate = new_fps + self.drop_frame = False def set_fractional(self, state: bool) -> None: """Set if the Timecode is to be represented with fractional seconds. @@ -317,10 +347,17 @@ def frames_to_tc( 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 - else: + 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: + # 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 in a day - timecode rolls over after 24 hours. # For drop frame, derived from frames_per_10_minutes to stay exact. @@ -401,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}"