From d6b496ad3aca6140e0a0448bf95e51969ee3b612 Mon Sep 17 00:00:00 2001 From: cubicibo <55701024+cubicibo@users.noreply.github.com> Date: Fri, 12 Jan 2024 14:09:03 +0100 Subject: [PATCH 1/4] Add realtime and systemtime getters. --- timecode/__init__.py | 64 +++++++++++++++++++++++++++++++++++--------- 1 file changed, 52 insertions(+), 12 deletions(-) diff --git a/timecode/__init__.py b/timecode/__init__.py index 6d76594..ee7914b 100644 --- a/timecode/__init__.py +++ b/timecode/__init__.py @@ -153,9 +153,9 @@ def framerate(self, framerate): Fraction: If the current version of Python supports (which it should) then Fraction is also accepted. """ - # Convert rational frame rate to float - numerator = None - denominator = None + # 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: @@ -167,15 +167,6 @@ def framerate(self, framerate): if isinstance(framerate, tuple): numerator, denominator = framerate - try: - from fractions import Fraction - - if isinstance(framerate, Fraction): - numerator = framerate.numerator - denominator = framerate.denominator - except ImportError: - pass - if numerator and denominator: framerate = round(float(numerator) / float(denominator), 2) if framerate.is_integer(): @@ -185,15 +176,20 @@ def framerate(self, framerate): if isinstance(framerate, (int, float)): framerate = str(framerate) + 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"])): self._int_framerate = 24 + self._ntsc_framerate = True elif framerate in ["ms", "1000"]: self._int_framerate = 1000 self.ms_frame = True @@ -379,6 +375,50 @@ def tc_to_string(self, hrs, mins, secs, frs): hrs, mins, secs, self.frame_delimiter, frs ) + def to_systemtime(self, as_float=False): + """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. + """ + hh, mm, ss, ff = self.frames_to_tc(self.frames + 1) + 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 "{:02d}:{:02d}:{:02d}.{:03d}".format(hh, mm, ss, round(ms*1000)) + + def to_realtime(self, as_float=False): + """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 + + # "int_framerate" frames is one second in NTSC time + if self._ntsc_framerate: + ts_float *= 1.001 + if as_float: + return ts_float + + f_fmtdivmod = lambda x: (int(x[0]), x[1]) + hh, ts_float = f_fmtdivmod(divmod(ts_float, 3600)) + mm, ts_float = f_fmtdivmod(divmod(ts_float, 60)) + ss, ts_float = f_fmtdivmod(divmod(ts_float, 1)) + ms = round(ts_float*1000) + + return "{:02d}:{:02d}:{:02d}.{:03d}".format(hh, mm, ss, ms) + @classmethod def parse_timecode(cls, timecode): """Parse the given timecode string. From 6632d62372b75273a2424484eb4b21018aca181a Mon Sep 17 00:00:00 2001 From: cubicibo <55701024+cubicibo@users.noreply.github.com> Date: Wed, 17 Jan 2024 09:27:37 +0100 Subject: [PATCH 2/4] Preserve drop frame flag with elementary operations. --- timecode/__init__.py | 11 +++++++---- 1 file changed, 7 insertions(+), 4 deletions(-) diff --git a/timecode/__init__.py b/timecode/__init__.py index ee7914b..089815c 100644 --- a/timecode/__init__.py +++ b/timecode/__init__.py @@ -652,6 +652,7 @@ def __add__(self, other): """ # duplicate current one tc = Timecode(self.framerate, frames=self.frames) + tc.drop_frame = self.drop_frame if isinstance(other, Timecode): tc.add_frames(other.frames) @@ -685,8 +686,9 @@ def __sub__(self, other): raise TimecodeError( "Type {} not supported for arithmetic.".format(other.__class__.__name__) ) - - return Timecode(self.framerate, frames=abs(subtracted_frames)) + tc = Timecode(self.framerate, frames=abs(subtracted_frames)) + tc.drop_frame = self.drop_frame + return tc def __mul__(self, other): """Return a new Timecode instance with multiplied value. @@ -709,8 +711,9 @@ def __mul__(self, other): raise TimecodeError( "Type {} not supported for arithmetic.".format(other.__class__.__name__) ) - - return Timecode(self.framerate, frames=multiplied_frames) + tc = Timecode(self.framerate, frames=multiplied_frames) + tc.drop_frame = self.drop_frame + return tc def __div__(self, other): """Return a new Timecode instance with divided value. From 55a88d6f657315d4746c85505e2b0825895bca27 Mon Sep 17 00:00:00 2001 From: cubicibo <55701024+cubicibo@users.noreply.github.com> Date: Wed, 17 Jan 2024 15:19:10 +0100 Subject: [PATCH 3/4] Return self for ms_frame TC. --- timecode/__init__.py | 13 ++++++++++--- 1 file changed, 10 insertions(+), 3 deletions(-) diff --git a/timecode/__init__.py b/timecode/__init__.py index 089815c..f76b015 100644 --- a/timecode/__init__.py +++ b/timecode/__init__.py @@ -296,7 +296,7 @@ def tc_to_frames(self, timecode): return frame_number + 1 # frames - def frames_to_tc(self, frames): + def frames_to_tc(self, frames, skip_rollover = False): """Convert frames back to timecode. Args: @@ -328,7 +328,8 @@ def frames_to_tc(self, frames): # If frame_number is greater than 24 hrs, next operation will rollover # clock - frame_number %= frames_per_24_hours + if not skip_rollover: + frame_number %= frames_per_24_hours if self.drop_frame: d = frame_number // frames_per_10_minutes @@ -385,7 +386,10 @@ def to_systemtime(self, as_float=False): Returns: str: The "system time" timestamp of the Timecode. """ - hh, mm, ss, ff = self.frames_to_tc(self.frames + 1) + 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: @@ -405,6 +409,9 @@ def to_realtime(self, as_float=False): #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 From a280074f3e3f671e8810b7d5a9297a279fab9a8f Mon Sep 17 00:00:00 2001 From: cubicibo <55701024+cubicibo@users.noreply.github.com> Date: Wed, 17 Jan 2024 15:19:37 +0100 Subject: [PATCH 4/4] Unit test realtime, systemtime and drop_frame flag. --- tests/test_timecode.py | 75 ++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 75 insertions(+) diff --git a/tests/test_timecode.py b/tests/test_timecode.py index 97689d3..d1a1160 100644 --- a/tests/test_timecode.py +++ b/tests/test_timecode.py @@ -646,6 +646,81 @@ def test_toggle_fractional_frame_3(): assert tc.__repr__() == "19:23:14:23" +def test_timestamp_realtime_1(): + frames = 12345 + ts = frames*1/24 + assert Timecode(24, frames=frames).to_realtime(True) == ts + + +def test_timestamp_realtime_2(): + tc = Timecode(50, start_seconds=1/50) + assert 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))) + + #- "[...] 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) + + +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) + + +def test_timestamp_systemtime_1(): + """ + TC with integer framerate always have system time equal to elapsed time. + """ + 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' + + +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 tc.to_systemtime() != tc.to_realtime() + + +def test_timestamp_systemtime_3(): + """ + TC with DF NTSC framerate have system time roughly equal to real time. + 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' + #Check if we have the expected drift at 24h + assert abs(tc.to_systemtime(True) - tc.to_realtime(True) - 24*3600e-6) < 1e-6 + + +def test_add_const_dropframe_flag(): + tc1 = Timecode(29.97, "00:00:00:00", force_non_drop_frame=True) + assert (tc1 + 1).drop_frame is False + + +def test_add_tc_dropframe_flag(): + tc1 = Timecode(29.97, "00:00:00:00", force_non_drop_frame=True) + tc2 = Timecode(29.97, "00:00:00;00") + + # Left operand drop_frame flag is preserved + assert (tc1 + tc2).drop_frame is False + assert (tc2 + tc1).drop_frame is True + + def test_ge_overload(): tc1 = Timecode(24, "00:00:00:00") tc2 = Timecode(24, "00:00:00:00")