Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 4 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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'
```
Expand Down Expand Up @@ -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:
Expand Down
177 changes: 95 additions & 82 deletions src/timecode/timecode.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@

import math
from contextlib import suppress
from fractions import Fraction
from typing import TYPE_CHECKING, overload

with suppress(ImportError):
Expand All @@ -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.

Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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:
Expand All @@ -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.
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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

Expand Down Expand Up @@ -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}"
Expand Down
44 changes: 23 additions & 21 deletions tests/test_timecode.py
Original file line number Diff line number Diff line change
@@ -1,4 +1,6 @@
#!-*- coding: utf-8 -*-
from fractions import Fraction

import pytest

from timecode import Timecode, TimecodeError
Expand Down Expand Up @@ -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


Expand Down Expand Up @@ -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():
Expand All @@ -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"

Expand Down Expand Up @@ -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):
Expand Down Expand Up @@ -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")
Expand Down
Loading