diff --git a/src/timecode/__init__.py b/src/timecode/__init__.py index 49ab1a3..419da5c 100644 --- a/src/timecode/__init__.py +++ b/src/timecode/__init__.py @@ -1,2 +1,2 @@ from timecode._version import __version__ # noqa: F401 -from timecode.timecode import Timecode, TimecodeError # noqa: F401 +from timecode.timecode import Timecode # noqa: F401 diff --git a/src/timecode/helpers.py b/src/timecode/helpers.py new file mode 100644 index 0000000..fc8fd78 --- /dev/null +++ b/src/timecode/helpers.py @@ -0,0 +1,183 @@ +"""Helper class for Timecode handling and byproducts.""" + +from __future__ import annotations + +import sys +from fractions import Fraction +from typing import NewType + +if sys.version_info >= (3, 11): + _frate_type = Fraction | str | float | tuple[int, int] +else: + from typing import Union + _frate_type = Union[Fraction, str, float, tuple[int, int]] + +_Framerate = NewType("_Framerate", _frate_type) + +class _Timestamp: + def __init__(self, ts: Fraction, usec_precision: bool = False) -> None: + if ts < 0: + raise ValueError(f"Timestamp cannot be negative, got {ts}.") + self.usec_precision = usec_precision + self._exact_ts = ts + + def __float__(self) -> float: + """Convert this _Timestamp instance to a float (timestamp in seconds). + + Returns: + float: timestamp value in seconds of this instance + """ + return float(self._exact_ts) + + def total_seconds(self) -> float: + """Return the time in seconds of this Timestamp instance as a float. + + Returns: + float: timestamp value in seconds of this instance + + Truncation is possible, it's not an exact representation. + """ + return float(self) + + def exact(self) -> Fraction: + """Return the time in seconds of this Timestamp instance as a fraction. + + Returns: + Fraction: exact timestamp value in seconds of this instance. + """ + return self._exact_ts + + def __eq__(self, other: _Timestamp | float | Fraction) -> bool: + """Equal implementation for _Timestamp instance. + + Args: + other (_Timestamp | int | float | Fraction): object to compare + instance to. + + Returns: + Result of the operation. + """ + if isinstance(other, __class__): + return self._exact_ts == other._exact_ts + if isinstance(other, Fraction): + return self._exact_ts == other + if isinstance(other, (float, int)): + return float(self) == other + return NotImplemented + + def __ne__(self, other: _Timestamp | float | Fraction) -> bool: + """Not equal implementation for _Timestamp instance. + + Args: + other (_Timestamp | int | float | Fraction): object to compare + instance to. + + Returns: + Result of the operation. + """ + if isinstance(other, __class__): + return self._exact_ts != other._exact_ts + if isinstance(other, Fraction): + return self._exact_ts != other + if isinstance(other, (float, int)): + return float(self) != other + return NotImplemented + + def __lt__(self, other: _Timestamp | float | Fraction) -> bool: + """Less than implementation for _Timestamp instance. + + Args: + other (_Timestamp | int | float | Fraction): object to compare + instance to. + + Returns: + Result of the operation. + """ + if isinstance(other, __class__): + return self._exact_ts < other._exact_ts + if isinstance(other, Fraction): + return self._exact_ts < other + if isinstance(other, (float, int)): + return float(self) < other + return NotImplemented + + def __gt__(self, other: _Timestamp | float | Fraction) -> bool: + """Greater than implementation for _Timestamp instance. + + Args: + other (_Timestamp | int | float | Fraction): object to compare + instance to. + + Returns: + Result of the operation. + """ + if isinstance(other, __class__): + return self._exact_ts > other._exact_ts + if isinstance(other, Fraction): + return self._exact_ts > other + if isinstance(other, (float, int)): + return float(self) > other + return NotImplemented + + def __le__(self, other: _Timestamp | float | Fraction) -> bool: + """Less or equal implementation for _Timestamp instance. + + Args: + other (_Timestamp | int | float | Fraction): object to compare + instance to. + + Returns: + Result of the operation. + """ + if isinstance(other, __class__): + return self._exact_ts <= other._exact_ts + if isinstance(other, Fraction): + return self._exact_ts <= other + if isinstance(other, (float, int)): + return float(self) <= other + return NotImplemented + + def __ge__(self, other: _Timestamp | float | Fraction) -> bool: + """Greater or equal implementation for _Timestamp instance. + + Args: + other (_Timestamp | int | float | Fraction): object to compare + instance to. + + Returns: + Result of the operation. + """ + if isinstance(other, __class__): + return self._exact_ts >= other._exact_ts + if isinstance(other, Fraction): + return self._exact_ts >= other + if isinstance(other, (float, int)): + return float(self) >= other + return NotImplemented + + def __str__(self) -> str: + """Convert the _Timestamp instance to a timestamp string. + + Returns: + string: Timestamp string of this _Timestamp instance. + """ + hh = int(self._exact_ts // 3600) + mm = int(self._exact_ts // 60) % 60 + ss = int(self._exact_ts % 60) + decimal_part = (self._exact_ts - int(self._exact_ts))*1000 + + if self.usec_precision: + s_decimal_part = f"{round(decimal_part*1000):>06}" + else: + s_decimal_part = f"{round(decimal_part):03}" + return f"{hh:02d}:{mm:02d}:{ss:02d}.{s_decimal_part}" + + def __repr__(self) -> str: + """Represent a _Timestamp instance. + + Returns: + string: representation of this _Timestamp instance. + """ + usec_part = f", usec_precision={self.usec_precision}" * self.usec_precision + return f"{__class__.__name__}({self._exact_ts}{usec_part})" +#### diff --git a/src/timecode/timecode.py b/src/timecode/timecode.py index 6a0e2b3..d7162c8 100644 --- a/src/timecode/timecode.py +++ b/src/timecode/timecode.py @@ -4,58 +4,118 @@ from __future__ import annotations import math -from contextlib import suppress -from typing import TYPE_CHECKING, overload - -with suppress(ImportError): - from typing import Literal +import sys +from fractions import Fraction +from typing import TYPE_CHECKING +from .helpers import _Framerate, _Timestamp 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 - +if sys.version_info >= (3, 11): + from typing import Self +else: + from typing_extensions import Self + +#%% class Timecode: """The main timecode class. Does all the calculation over frames, so the main data it holds is frames, then when required it converts the frames to a timecode by using the frame rate setting. - + Args: - framerate (str | int | float | Fraction): The frame rate of the + framerate (Fraction | str | int | float): The frame rate of the Timecode instance. If a str is given it should be one of ['23.976', '23.98', '24', '25', '29.97', '30', '50', '59.94', '60', - 'NUMERATOR/DENOMINATOR', ms'] where "ms" equals to 1000 fps. + 'NUMERATOR/DENOMINATOR', 'ms'] where "ms" equals to 1000 fps. Otherwise, any integer or Fractional value is accepted. Can not be skipped. Setting the framerate will automatically set the :attr:`.drop_frame` attribute to correct value. - start_timecode (None | str): The start timecode. Use this to be able to - set the timecode of this Timecode instance. It can be skipped and - then the frames attribute will define the timecode, and if it is - also skipped then the start_second attribute will define the start - timecode, and if start_seconds is also skipped then the default - value of '00:00:00:00' will be used. When using 'ms' frame rate, - timecodes like '00:11:01.040' use '.040' as frame number. When used - with other frame rates, '.040' represents a fraction of a second. - So '00:00:00.040' at 25fps is 1 frame. + start_timecode (None | str | Timecode): The start timecode. Use this to + be able to set the timecode of this Timecode instance. It can be + skipped and then the frames attribute will define the timecode, and + if it is also skipped then the start_second attribute will define + the start timecode, and if start_seconds is also skipped then the + default value of '00:00:00:00' will be used. When using 'ms' frame + rate, timecodes like '00:11:01.040' use '.040' as frame number. + When used with other frame rates, '.040' represents a fraction of a + second. So '00:00:00.040' at 25fps is 1 frame. start_seconds (int | float): A float or integer value showing the seconds. frames (int): Timecode objects can be initialized with an integer number showing the total frames. force_non_drop_frame (bool): If True, uses Non-Dropframe calculation - for 29.97 or 59.94 only. Has no meaning for any other framerate. It - is False by default. + for NTSC-rate multiples of 29.97 (59.94, 119.88) only. Has no + meaning for any other framerate. It is False by default. + reference_time_on_display (bool): If True, specify the raster scan time + reference to the top of the display rather than the bottom. + True: The 1st frame wall-clock time is 0.0 sec. + False (default): The 1st frame wall-clock time is 1/fps sec: the + drawing duration of the 1st frame is included. + This parameter affects the reference for start_seconds. """ + def __init__( + self, + framerate: _Framerate, + start_timecode: str | Self | None = None, + start_seconds: None | float = None, + frames: int | None = None, + force_non_drop_frame: bool = False, + reference_time_on_display: bool = False, + ) -> None: + self.fraction_frame = False + self.usec_timestamps = False + self.drop_frame = False + + self.framerate = framerate + + # Do not set drop_frame to true in the framerate setter: the user + # could be switching NTSC rates with force_non_drop_frame=True + # clearing drop_frame every time would be an annoyance. + if not force_non_drop_frame and (self._int_framerate % 30) == 0: + self.drop_frame = self._is_ntsc_rate + + # if true, the first frame display at t=0 sec and not t=1/fps + # this impacts start_seconds and the time getters. + self.reference_time_on_display = reference_time_on_display + + self._dispatch_set_frames(start_timecode=start_timecode, + start_seconds=start_seconds, + frames=frames) + # set a default + if getattr(self, "_frames", None) is None: + self.frames = self.tc_to_frames("00:00:00:00") + + #### + + def _dispatch_set_frames(self, **kwargs) -> None: + """Helper to dispatch the arguments to set the Timecode frames count. + + Args: + kwargs (dict): dictionary of possible input values to set the frame + count. The following order of priority applies: + 1. start_timecode: Timecode string, or Timecode object. + 2. frames: frames count of the Timecode. + 3. start_seconds: float or fraction in seconds. + """ + if (start_timecode := kwargs.get("start_timecode")) is not None: + self.frames = self.tc_to_frames(start_timecode) + elif (frames := kwargs.get("frames")) is not None: + self.frames = frames + elif (start_seconds := kwargs.get("start_seconds")) is not None: + if self.reference_time_on_display: + start_seconds += Fraction(1, self._int_framerate) + + if start_seconds <= 0: + raise ValueError("``start_seconds`` argument can not be 0") + self.frames = self.float_to_tc(start_seconds) + #### @staticmethod - def _is_ntsc_rate(fps: float) -> tuple[bool, int]: + def _check_ntsc_rate(fps: Fraction) -> tuple[bool, int]: """Check if framerate is NTSC (multiple of 24000/1001 or 30000/1001). NTSC rates follow the pattern: nominal_rate * 1000/1001 @@ -79,38 +139,82 @@ def _is_ntsc_rate(fps: float) -> tuple[bool, int]: return is_ntsc, int_fps - def __init__( - self, - framerate: str | float | Fraction, - start_timecode: None | str = None, - start_seconds: None | float = None, - frames: None | int = None, - force_non_drop_frame: bool = False, - ) -> None: - self.force_non_drop_frame = force_non_drop_frame + @property + def framerate(self) -> Fraction: + """Framerate getter. - self.drop_frame = False + Returns: + Fraction: The Timecode framerate, as a fraction of two integers. + """ + return self._framerate - self.ms_frame = False - self.fraction_frame = False - self._int_framerate: None | int = None - self._framerate: None | str | int | float | Fraction = None - self.framerate = framerate # type: ignore - self._frames: None | int = None - - # attribute override order - # start_timecode > frames > start_seconds - if start_timecode: - self.frames = self.tc_to_frames(start_timecode) - elif frames is not None: - self.frames = frames - elif start_seconds is not None: - if start_seconds == 0: - raise ValueError("``start_seconds`` argument can not be 0") - self.frames = self.float_to_tc(start_seconds) + @framerate.setter + def framerate(self, new_framerate: _Framerate) -> None: + """Set a framerate to the Timecode instance. + + Args: + new_framerate (_Framerate): The framerate to use + + The provided rate is converted to a fraction. Special values "ms" and + "frames" are accepted to specify 1/1000 or 1/1 (resp.) + + """ + if isinstance(new_framerate, str): + if new_framerate == "ms": + new_framerate = 1000 + elif new_framerate == "frames": + new_framerate = 1 + + if isinstance(new_framerate, (tuple, list)): + new_framerate = tuple(map(int, new_framerate)) + new_fps = Fraction(*new_framerate) else: - # use default value of 00:00:00:00 - self.frames = self.tc_to_frames("00:00:00:00") + new_fps = Fraction(new_framerate) + if new_fps.numerator <= 0: + raise ValueError("Invalid framerate (zero or negative).") + + self.ms_frame = (new_fps == 1000) + if not self.ms_frame: + self._is_ntsc_rate, self._int_framerate = \ + __class__._check_ntsc_rate(new_fps) + else: + self._is_ntsc_rate, self._int_framerate = False, int(new_fps) + + # Fix ambiguous values like 23976/1000 or 23.98. + if self._is_ntsc_rate and new_fps.denominator != 1001: + new_fps = Fraction(self._int_framerate * 1000, 1001) + + # No more a NTSC framerate. Only clear as the TC could be forced to NDF + if self.drop_frame and \ + (not self._is_ntsc_rate or (self._int_framerate % 30) > 0): + self.drop_frame = False + self._framerate = new_fps + + def to_systemtime(self) -> _Timestamp: + """Convert a Timecode to the video system timestamp. + + For NTSC rates, the video system time is not the wall-clock one. + + Returns: + _Timestamp of the "system time" of the Timecode. + """ + display_delay = int(not self.reference_time_on_display) + hh, mm, ss, ff = self.frames_to_tc(self.frames, skip_rollover=True) + ts = ss + 60 * (mm + 60 * hh) + ts += Fraction((ff + display_delay), self._int_framerate) + return _Timestamp(ts, usec_precision=self.usec_timestamps) + + def to_realtime(self) -> _Timestamp: + """Convert a Timecode to the wall-clock (real time) timestamp. + + For NTSC rates, the real time value differs from the system time. + + Returns: + _Timestamp of the "real time" of the Timecode. + """ + ts = Fraction(self.frames, self.framerate) + ts -= Fraction(int(self.reference_time_on_display), self.framerate) + return _Timestamp(ts, usec_precision=self.usec_timestamps) @property def frames(self) -> int: @@ -142,97 +246,7 @@ def frames(self, frames: int) -> None: f"integer bigger than zero, not {frames}" ) self._frames = frames - - @property - def framerate(self) -> str: - """Return the _framerate attribute. - - Returns: - str: The frame rate of this Timecode instance. - """ - return self._framerate # type: ignore - - @framerate.setter - def framerate(self, framerate: float | str | tuple[int, int] | Fraction) -> None: - """Set the framerate attribute. - - Args: - framerate (int | float | str | tuple[int, int] | Fraction): Several - different type is accepted for this argument: - - int, float: It is directly used. - str: Is used for setting DF Timecodes and possible values are - ["23.976", "23.98", "29.97", "59.94", "ms", "1000", "frames"] where - "ms" and "1000" results in to a milliseconds based Timecode and - "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 - 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 - - def set_fractional(self, state: bool) -> None: - """Set if the Timecode is to be represented with fractional seconds. - - Args: - state (bool): If set to True the current Timecode instance will be - represented with a fractional seconds (will have a "." in the frame - separator). - """ - self.fraction_frame = state - + def set_timecode(self, timecode: str | Timecode) -> None: """Set the frames by using the given timecode. @@ -242,7 +256,7 @@ def set_timecode(self, timecode: str | Timecode) -> None: """ self.frames = self.tc_to_frames(timecode) - def float_to_tc(self, seconds: float) -> int: + def float_to_tc(self, seconds: float | Fraction) -> int: """Return the number of frames in the given seconds using the current instance. Args: @@ -250,7 +264,7 @@ def float_to_tc(self, seconds: float) -> int: frame rate for proper calculation. Returns: - int: The number of frames in the given seconds.ß + int: The number of frames in the given seconds. """ return int(seconds * self._int_framerate) @@ -277,14 +291,8 @@ 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) - ) - # Number of drop frames is 6% of framerate rounded to nearest integer - drop_frames = round(ffps * 0.066666) if self.drop_frame else 0 + drop_frames = round(self.framerate * 0.066666) if self.drop_frame else 0 # We don't need the exact framerate anymore, we just need it rounded to # nearest integer @@ -304,7 +312,7 @@ def tc_to_frames(self, timecode: str | Timecode) -> int: self.fraction_frame = True fraction = timecode.rsplit(".", 1)[1] - frames = round(float("." + fraction) * ffps) + frames = round(float("." + fraction) * float(self.framerate)) frame_number = ( (hour_frames * hours) @@ -331,10 +339,10 @@ def frames_to_tc( 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) + ffps = round(self.framerate, 2) drop_frames = round(ffps * 0.066666) else: - ffps = float(self._int_framerate) + ffps = self._int_framerate drop_frames = 0 # Number of frames per ten minutes @@ -345,7 +353,7 @@ def frames_to_tc( # 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 + frames_per_minute = int(self._int_framerate * 60) - drop_frames frame_number = frames - 1 @@ -368,14 +376,13 @@ def frames_to_tc( frs: int | float = frame_number % ifps if self.fraction_frame: - frs = round(frs / float(ifps), 3) + frs = round(frs / ifps, 3) secs = int((frame_number // ifps) % 60) mins = int(((frame_number // ifps) // 60) % 60) hrs = int(((frame_number // ifps) // 60) // 60) return hrs, mins, secs, frs - def tc_to_string(self, hrs: int, mins: int, secs: int, frs: float) -> str: """Return the string representation of a Timecode with given info. @@ -386,7 +393,7 @@ def tc_to_string(self, hrs: int, mins: int, secs: int, frs: float) -> str: frs (int | float): The frames portion of the Timecode. Returns: - str: The string representation of this Timecode.ßß + str: The string representation of this Timecode. """ if self.fraction_frame: return f"{hrs:02d}:{mins:02d}:{secs + frs:06.3f}" @@ -399,86 +406,6 @@ def tc_to_string(self, hrs: int, mins: int, secs: int, frs: float) -> str: hrs, mins, secs, self.frame_delimiter, frs ) - @overload - def to_systemtime(self, as_float: Literal[True]) -> float: - pass - - @overload - def to_systemtime(self, as_float: Literal[False]) -> str: - pass - - def to_systemtime(self, as_float: bool = False) -> str | float: # type:ignore - """Convert a Timecode to the video system timestamp. - - 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. - - Returns: - str: The "system time" timestamp of the Timecode. - """ - if self.ms_frame: - 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 - if as_float: - return hh * 3600 + mm * 60 + ss + ms - return f"{hh:02d}:{mm:02d}:{ss:02d}.{round(ms * 1000):03d}" - - @overload - def to_realtime(self, as_float: Literal[True]) -> float: - pass - - @overload - def to_realtime(self, as_float: Literal[False]) -> str: - pass - - def to_realtime(self, as_float: bool = False) -> str | float: # type:ignore - """Convert a Timecode to a "real time" timestamp. - - Reference: SMPTE 12-1 §5.1.2 - - Args: - as_float (bool): Return the time as a float number of seconds. - - Returns: - str: The "real time" timestamp of the Timecode. - """ - # float property is in the video system time grid - ts_float = self.float - - if self.ms_frame: - return ts_float - (1e-3) if as_float else str(self) - - # "int_framerate" frames is one second in NTSC time - if self._ntsc_framerate: - ts_float *= 1.001 - if as_float: - return ts_float - - def f_fmt_divmod(x: tuple[int, float]) -> tuple[int, float]: - """Helper to format divmod results. - - Args: - x (tuple): The divmod result. - - Returns: - tuple[int, float]: Formatted divmod result. - """ - return (int(x[0]), x[1]) - - hh, ts_float = f_fmt_divmod(divmod(ts_float, 3600)) - mm, ts_float = f_fmt_divmod(divmod(ts_float, 60)) - ss, ts_float = f_fmt_divmod(divmod(ts_float, 1)) - ms = round(ts_float * 1000) - - return f"{hh:02d}:{mm:02d}:{ss:02d}.{ms:03d}" - @classmethod def parse_timecode(cls, timecode: int | str) -> tuple[int, int, int, int]: """Parse the given timecode string. @@ -534,7 +461,7 @@ def frame_delimiter(self) -> str: return ":" def __iter__(self) -> Iterator[Self]: - """Yield and iterator. + """Yield an iterator. Yields: Timecode: Yields this Timecode instance. @@ -551,6 +478,16 @@ def next(self) -> Self: self.add_frames(1) return self + def set_fractional(self, state: bool) -> None: + """Set if the Timecode is to be represented with fractional seconds. + + Args: + state (bool): If set to True the current Timecode instance will be + represented with a fractional seconds (will have a "." in the frame + separator). + """ + self.fraction_frame = state + def back(self) -> Self: """Subtract one frame from this Timecode to go back one frame. @@ -596,6 +533,12 @@ def div_frames(self, frames: int) -> None: """ self.frames = int(self.frames / frames) + def _copy_props_from(self, other: Timecode) -> None: + self.drop_frame = other.drop_frame + self.fraction_frame = other.fraction_frame + self.usec_timestamps = other.usec_timestamps + self.reference_time_on_display = other.reference_time_on_display + def __eq__(self, other: int | str | Timecode | object) -> bool: """Override the equality operator. @@ -724,15 +667,12 @@ def __add__(self, other: int | Timecode) -> Timecode: other (int | Timecode): Either and int value or a Timecode in which the frames are used for the calculation. - Raises: - TimecodeError: If the other is not an int or Timecode. - Returns: Timecode: The resultant Timecode instance. """ # duplicate current one tc = Timecode(self.framerate, frames=self.frames) - tc.drop_frame = self.drop_frame + tc._copy_props_from(self) if isinstance(other, Timecode): tc.add_frames(other.frames) @@ -750,9 +690,6 @@ def __sub__(self, other: int | Timecode) -> Timecode: other (int | Timecode): The number to subtract, either an integer or another Timecode in which the number of frames is subtracted. - Raises: - TimecodeError: If the other is not an int or Timecode. - Returns: Timecode: The resultant Timecode instance. """ @@ -763,7 +700,7 @@ def __sub__(self, other: int | Timecode) -> Timecode: else: return NotImplemented tc = Timecode(self.framerate, frames=abs(subtracted_frames)) - tc.drop_frame = self.drop_frame + tc._copy_props_from(self) return tc def __mul__(self, other: int | Timecode) -> Timecode: @@ -773,9 +710,6 @@ def __mul__(self, other: int | Timecode) -> Timecode: other (int | Timecode): The multiplier either an integer or another Timecode in which the number of frames is used as the multiplier. - Raises: - TimecodeError: If the other is not an int or Timecode. - Returns: Timecode: The resultant Timecode instance. """ @@ -786,7 +720,7 @@ def __mul__(self, other: int | Timecode) -> Timecode: else: return NotImplemented tc = Timecode(self.framerate, frames=multiplied_frames) - tc.drop_frame = self.drop_frame + tc._copy_props_from(self) return tc def __div__(self, other: int | Timecode) -> Timecode: @@ -796,20 +730,19 @@ def __div__(self, other: int | Timecode) -> Timecode: other (int | Timecode): The denominator either an integer or another Timecode in which the number of frames is used as the denominator. - Raises: - TimecodeError: If the other is not an int or Timecode. - Returns: Timecode: The resultant Timecode instance. """ if isinstance(other, Timecode): - div_frames = int(float(self.frames) / float(other.frames)) + div_frames = int(self.frames / other.frames) elif isinstance(other, int): - div_frames = int(float(self.frames) / float(other)) + div_frames = int(self.frames / other) else: return NotImplemented - return Timecode(self.framerate, frames=div_frames) + tc = Timecode(self.framerate, frames=div_frames) + tc._copy_props_from(self) + return tc def __truediv__(self, other: int | Timecode) -> Timecode: """Return a new Timecode instance with divided value. @@ -823,13 +756,34 @@ def __truediv__(self, other: int | Timecode) -> Timecode: """ return self.__div__(other) + def __float__(self) -> float: + """Convert this Timecode instance to a float representation (seconds). + + Prefer the usage of the {system, real}time getters for accuracy. + + Returns: + float: The float representation (seconds). + """ + offset = int(self.reference_time_on_display) + seconds = float((self.frames - offset)/self._int_framerate) + return math.nextafter(seconds, math.inf) + + def __str__(self) -> str: + """Return the actual Timecode as a string. + + Returns: + str: The string of this Timecode. + """ + return self.tc_to_string(*self.frames_to_tc(self.frames)) + def __repr__(self) -> str: """Return the string representation of this Timecode instance. Returns: str: The string representation of this Timecode instance. """ - return self.tc_to_string(*self.frames_to_tc(self.frames)) + # use frames= as that is agnostic to drop_frame + return f"{__class__.__name__}('{self.framerate}', frames={self.frames})" @property def hrs(self) -> int: @@ -888,9 +842,30 @@ def float(self) -> float: Returns: float: The seconds as float. """ - time_value = float(self.frames) / float(self._int_framerate) - return math.nextafter(time_value, math.inf) + return float(self) +#### + +#%% +class TimecodeBuilder: + """Helper class to pre-configure instantiation of Timecodes. + + A list of kwargs of class Timecode can be provided to the builder, which + will be used when the builder instance is called to create new Timecodes. + + Args: + kwargs (dict): list of pre-configured arguments for the Timecodes + instantiated by calling this builder. Refer to Timecode docu. + """ + def __init__(self, **kwargs) -> None: + self.kwargs = kwargs -class TimecodeError(Exception): - """Raised when an error occurred in timecode calculation.""" + def __call__(self, *args, **kwargs) -> Timecode: + """Create a Timecode combining the preconfigured and user arguments. + + Returns: + Timecode: timecode instance given the arguments. + """ + kwargs = self.kwargs | kwargs + return Timecode(*args, **kwargs) +#### diff --git a/tests/test_timecode.py b/tests/test_timecode.py index 99efb52..2d64035 100644 --- a/tests/test_timecode.py +++ b/tests/test_timecode.py @@ -1,11 +1,10 @@ #!-*- coding: utf-8 -*- import pytest -from timecode import Timecode, TimecodeError - +from timecode import Timecode +from fractions import Fraction import random - @pytest.mark.parametrize( "args,kwargs", [ @@ -143,9 +142,9 @@ def test_repr_overload(args, kwargs, expected_result, operator): """Several timecode initialization.""" tc = Timecode(*args, **kwargs) if operator: - assert expected_result == tc.__repr__() + assert expected_result == tc.__str__() else: - assert expected_result != tc.__repr__() + assert expected_result != tc.__str__() def test_repr_overload_2(): @@ -335,7 +334,6 @@ 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._int_framerate == 1 @@ -981,7 +979,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 +1006,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" @@ -1105,8 +1103,8 @@ def test_24_hour_limit_in_2997fps(): assert tc2.drop_frame assert 2589408 == tc2._frames - assert "00:00:00;21" == tc1.__repr__() - assert "23:59:59;29" == tc2.__repr__() + assert "00:00:00;21" == str(tc1) + assert "23:59:59;29" == str(tc2) assert "00:00:00;21" == (tc1 + tc2).__str__() assert "02:00:00;00" == (tc2 + 215785).__str__() @@ -1226,23 +1224,23 @@ def test_framerate_can_be_changed(): def test_rational_framerate_conversion(args, kwargs, frame_rate, int_framerate): """Fractional framerate conversion.""" tc = Timecode(*args, **kwargs) - assert frame_rate == tc.framerate + assert abs(float(frame_rate) - tc.framerate) < 5e-3 assert int_framerate == tc._int_framerate def test_rational_frame_delimiter_1(): tc = Timecode("24000/1000", frames=1) - assert ";" not in tc.__repr__() + assert ";" not in tc.__str__() def test_rational_frame_delimiter_2(): tc = Timecode("24000/1001", frames=1) - assert ";" not in tc.__repr__() + assert ";" not in tc.__str__() def test_rational_frame_delimiter_3(): tc = Timecode("30000/1001", frames=1) - assert ";" in tc.__repr__() + assert ";" in tc.__str__() def test_ms_vs_fraction_frames_1(): @@ -1266,49 +1264,49 @@ def test_ms_vs_fraction_frames_3(): def test_toggle_fractional_frame_1(): tc = Timecode(24, 421729315) - assert tc.__repr__() == "19:23:14:23" + assert tc.__str__() == "19:23:14:23" def test_toggle_fractional_frame_2(): tc = Timecode(24, 421729315) tc.set_fractional(True) - assert tc.__repr__() == "19:23:14.958" + assert tc.__str__() == "19:23:14.958" def test_toggle_fractional_frame_3(): tc = Timecode(24, 421729315) tc.set_fractional(False) - assert tc.__repr__() == "19:23:14:23" + assert tc.__str__() == "19:23:14:23" def test_timestamp_realtime_1(): frames = 12345 ts = frames * 1 / 24 - realtime = Timecode(24, frames=frames).to_realtime(True) + realtime = float(Timecode(24, frames=frames).to_realtime()) assert abs(realtime - ts) < 1e-09 def test_timestamp_realtime_2(): tc = Timecode(50, start_seconds=1 / 50) - assert tc.to_realtime() == "00:00:00.020" + assert str(tc.to_realtime()) == "00:00:00.020" def test_timestamp_realtime_3(): # SMPTE 12-1 §5.2.2: # - "When DF compensation is applied to NTSC TC, the deviation after one hour is approximately –3.6 ms" tc = Timecode(29.97, "00:59:59;29") - assert tc.to_realtime() == str(Timecode(1000, "01:00:00.000") - int(round(3.6))) + assert str(tc.to_realtime()) == str((Timecode(1000, "00:59:59.999") - int(round(3.6))).to_realtime()) # - "[...] The deviation accumulated over a 24-hour period is approximately –2.6 frames (–86 ms)" tc = Timecode(59.94, "23:59:59;59") - assert tc.to_realtime() == str(Timecode(1000, "24:00:00.000") - 86) + assert str(tc.to_realtime()) == str((Timecode(1000, "23:59:59.999") - 86).to_realtime()) def test_timestamp_realtime_4(): # SMPTE 12-1 §5.2.2 # - "Monotonically counting at int_framerate will yield a deviation of approx. +3.6 s in one hour of elapsed time." tc = Timecode(59.94, "00:59:59:59", force_non_drop_frame=True) - assert tc.to_realtime() == str(Timecode(1000, "01:00:00.000") + 3600) + assert str(tc.to_realtime()) == str((Timecode(1000, "00:59:59.999") + 3600).to_realtime()) def test_timestamp_systemtime_1(): @@ -1317,10 +1315,10 @@ def test_timestamp_systemtime_1(): """ tc50 = Timecode(50, "00:59:59:49") tc24 = Timecode(24, "00:59:59:23") - tcms = Timecode(1000, "01:00:00.000") - assert tc50.to_systemtime() == "01:00:00.000" - assert tc24.to_systemtime() == "01:00:00.000" - assert tcms.to_systemtime() == "01:00:00.000" + tcms = Timecode(1000, "00:59:59.999") + assert str(tc50.to_systemtime()) == "01:00:00.000" + assert str(tc24.to_systemtime()) == "01:00:00.000" + assert str(tcms.to_systemtime()) == "01:00:00.000" def test_timestamp_systemtime_2(): @@ -1328,7 +1326,7 @@ def test_timestamp_systemtime_2(): TC with NTSC framerate always have system time different to realtime. """ tc = Timecode(23.98, "00:59:59:23") - assert tc.to_systemtime() == "01:00:00.000" + assert str(tc.to_systemtime()) == "01:00:00.000" assert tc.to_systemtime() != tc.to_realtime() @@ -1338,9 +1336,9 @@ def test_timestamp_systemtime_3(): with a -3.6 ms drift per hour (SMPTE 12-1 §5.2.2). """ tc = Timecode(29.97, "23:59:59;29") - assert tc.to_systemtime() == "24:00:00.000" + assert str(tc.to_systemtime()) == "24:00:00.000" # Check if we have the expected drift at 24h - assert abs(tc.to_systemtime(True) - tc.to_realtime(True) - 24 * 3600e-6) < 1e-6 + assert abs(float(tc.to_systemtime()) - float(tc.to_realtime()) - 24 * 3600e-6) < 1e-6 def test_add_const_dropframe_flag(): @@ -1455,7 +1453,7 @@ def test_bug_report_30(): frame_idx = 50000 tc1 = Timecode(framerate, frames=frame_idx) - assert "00:34:43:07" == tc1.__repr__() + assert "00:34:43:07" == tc1.__str__() def test_bug_report_31_part1(): @@ -1508,7 +1506,7 @@ def test_set_timecode_method(): tc2 = Timecode("29.97", frames=1000) assert tc2.frames == 1000 - tc1.set_timecode(tc2.__repr__()) # this is interpreted as 24 + tc1.set_timecode(tc2.__str__()) # this is interpreted as 24 assert tc1.frames == 802 tc1.set_timecode(tc2) # this should be interpreted as 29.97 and 1000 frames @@ -1546,7 +1544,7 @@ def test_mult_frames_method_is_working_properly(): tc = Timecode("24") tc.mult_frames(10) assert tc.frames == 10 - assert tc.__repr__() == "00:00:00:09" + assert tc.__str__() == "00:00:00:09" def test_div_frames_method_is_working_properly(): @@ -1555,7 +1553,7 @@ def test_div_frames_method_is_working_properly(): assert tc.frames == 10 tc.div_frames(10) assert tc.frames == 1 - assert tc.__repr__() == "00:00:00:00" + assert tc.__str__() == "00:00:00:00" def test_eq_method_with_integers(): @@ -1647,7 +1645,7 @@ def test_rollover_for_23_98(): assert 2071849 == tc.frames tc.add_frames(24) assert 2071873 == tc.frames - assert "23:58:48:00" == tc.__repr__() + assert "23:58:48:00" == tc.__str__() @pytest.mark.parametrize( @@ -1734,10 +1732,10 @@ def test_generalized_ntsc_rates( # 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._is_ntsc_rate is True assert tc._int_framerate == int_framerate assert tc.drop_frame is is_drop - assert tc.framerate == framerate + assert abs(tc.framerate - float(framerate)) < 5e-3 # Test frame counting - one second should be int_framerate + 1 tc2 = Timecode(framerate, f"00:00:01{separator}00") @@ -1761,6 +1759,43 @@ 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._is_ntsc_rate is True assert tc._int_framerate == int_framerate assert tc.drop_frame is is_drop + +def test_repr_parse(): + #force NDF to verify there is no mangling even with a DF framerate + tc = Timecode('30000/1001', '12:34:56:21', force_non_drop_frame=True) + tc_eval = eval(repr(tc)) + + assert tc.frames == tc_eval.frames + assert tc.framerate == tc_eval.framerate + +def test_reference_time(): + framerate = Fraction(120000, 1001) + #start_seconds is in the system timebase, so use int_fps + tc1 = Timecode(framerate, start_seconds=1/120) + tc2 = Timecode(framerate, start_seconds=0, reference_time_on_display=True) + assert tc1 == tc2 + +def test_reference_time_frame_offset(): + framerate = Fraction(120000, 1001) + #start_seconds is in the system timebase, so use int_fps + tc1 = Timecode(framerate, start_seconds=600) + tc2 = Timecode(framerate, start_seconds=600, reference_time_on_display=True) + assert (tc1 + 1) == tc2 + +def test_reference_real_system_time_value(): + framerate = Fraction(48000, 1001) + tc = Timecode(framerate, frames=1, reference_time_on_display=True) + assert tc.frames == 1 + assert tc._is_ntsc_rate is True # needed for this test + + # Time reference on system starting to draw the first frame. + # only time where both are equal. + assert float(tc.to_realtime()) == 0.0 + assert float(tc.to_systemtime()) == 0.0 + + tc.reference_time_on_display = False + # No longer true, NTSC is slower than wall-clock + assert 0 < tc.to_systemtime() < tc.to_realtime()