Skip to content

Commit f915f99

Browse files
committed
[timecode] Combine all representations into a variant type
1 parent a501c3b commit f915f99

File tree

1 file changed

+95
-84
lines changed

1 file changed

+95
-84
lines changed

scenedetect/common.py

Lines changed: 95 additions & 84 deletions
Original file line numberDiff line numberDiff line change
@@ -147,6 +147,20 @@ def seconds(self) -> float:
147147
return float(self.time_base * self.pts)
148148

149149

150+
@dataclass(frozen=True)
151+
class _FrameNumber:
152+
"""Represents a time as a frame number."""
153+
154+
value: int
155+
156+
157+
@dataclass(frozen=True)
158+
class _Seconds:
159+
"""Represents a time in seconds."""
160+
161+
value: float
162+
163+
150164
class FrameTimecode:
151165
"""Object for frame-based timecodes, using the video framerate to compute back and
152166
forth between frame number and seconds/timecode.
@@ -172,24 +186,15 @@ def __init__(
172186
TypeError: Thrown if either `timecode` or `fps` are unsupported types.
173187
ValueError: Thrown when specifying a negative timecode or framerate.
174188
"""
175-
# NOTE: FrameTimecode will have either a `Timecode` representation, a `seconds`
176-
# representation, or only a frame number. We cache the calculated values for later use
177-
# for the parameters that are missing.
189+
self._time: ty.Union[_FrameNumber, _Seconds, Timecode]
190+
"""Internal time representation."""
178191
self._rate: Fraction = None
179192
"""Rate at which time passes between frames, measured in frames/sec."""
180-
self._frame_num = None
181-
"""Frame number which may be estimated."""
182-
self._timecode: ty.Optional[Timecode] = None
183-
"""Presentation timestamp from the backend."""
184-
self._seconds: ty.Optional[float] = None
185-
"""An explicit point in time."""
186193

187194
# Copy constructor.
188195
if isinstance(timecode, FrameTimecode):
189196
self._rate = timecode._rate if fps is None else fps
190-
self._frame_num = timecode._frame_num
191-
self._timecode = timecode._timecode
192-
self._seconds = timecode._seconds
197+
self._time = timecode._time
193198
return
194199

195200
if not isinstance(fps, (float, Fraction, FrameTimecode)):
@@ -215,31 +220,31 @@ def __init__(
215220

216221
# Timecode with a time base.
217222
if isinstance(timecode, Timecode):
218-
self._timecode = timecode
223+
self._time = timecode
219224
return
220225

221226
# Process the timecode value, storing it as an exact number of frames only if required.
222227
if isinstance(timecode, str) and timecode.isdigit():
223228
timecode = int(timecode)
224229

225230
if isinstance(timecode, str):
226-
self._seconds = self._timecode_to_seconds(timecode)
231+
self._time = _Seconds(self._timecode_to_seconds(timecode))
227232
elif isinstance(timecode, float):
228233
if timecode < 0.0:
229234
raise ValueError("Timecode frame number must be positive and greater than zero.")
230-
self._seconds = timecode
235+
self._time = _Seconds(timecode)
231236
elif isinstance(timecode, int):
232237
if timecode < 0:
233238
raise ValueError("Timecode frame number must be positive and greater than zero.")
234-
self._frame_num = timecode
239+
self._time = _FrameNumber(timecode)
235240
else:
236241
raise TypeError("Timecode format/type unrecognized.")
237242

238243
@property
239244
def frame_num(self) -> ty.Optional[int]:
240245
"""The frame number. This value will be an estimate if the video is VFR. Prefer using the
241246
`pts` property."""
242-
if self._timecode:
247+
if isinstance(self._time, Timecode):
243248
# We need to audit anything currently using this property to guarantee temporal
244249
# consistency when handling VFR videos (i.e. no assumptions on fixed frame rate).
245250
warnings.warn(
@@ -249,11 +254,11 @@ def frame_num(self) -> ty.Optional[int]:
249254
)
250255
# We can calculate the approx. # of frames by taking the presentation time and the
251256
# time base itself.
252-
(num, den) = (self._timecode.time_base * self._timecode.pts).as_integer_ratio()
257+
(num, den) = (self._time.time_base * self._time.pts).as_integer_ratio()
253258
return num / den
254-
if self._seconds is not None:
255-
return self._seconds_to_frames(self._seconds)
256-
return self._frame_num
259+
if isinstance(self._time, _Seconds):
260+
return self._seconds_to_frames(self._time.value)
261+
return self._time.value
257262

258263
@property
259264
def framerate(self) -> float:
@@ -264,15 +269,15 @@ def framerate(self) -> float:
264269
@property
265270
def time_base(self) -> Fraction:
266271
"""The time base in which presentation time is calculated."""
267-
if self._timecode:
268-
return self._timecode.time_base
272+
if isinstance(self._time, Timecode):
273+
return self._time.time_base
269274
return 1 / self._rate
270275

271276
@property
272277
def pts(self) -> int:
273278
"""The presentation timestamp of the frame in units of `time_base`."""
274-
if self._timecode:
275-
return self._timecode.pts
279+
if isinstance(self._time, Timecode):
280+
return self._time.pts
276281
return self.frame_num
277282

278283
def get_frames(self) -> int:
@@ -321,11 +326,11 @@ def equal_framerate(self, fps) -> bool:
321326
@property
322327
def seconds(self) -> float:
323328
"""The frame's position in number of seconds."""
324-
if self._timecode:
325-
return self._timecode.seconds
326-
if self._seconds:
327-
return self._seconds
328-
return float(self._frame_num / self._rate)
329+
if isinstance(self._time, Timecode):
330+
return self._time.seconds
331+
if isinstance(self._time, _Seconds):
332+
return self._time.value
333+
return float(self._time.value / self._rate)
329334

330335
def get_seconds(self) -> float:
331336
"""[DEPRECATED] Get the frame's position in number of seconds.
@@ -478,8 +483,8 @@ def _get_other_as_frames(self, other: ty.Union[int, float, str, "FrameTimecode"]
478483
raise ValueError(
479484
"FrameTimecode instances require equal framerate for frame-based arithmetic."
480485
)
481-
if other._frame_num is not None:
482-
return other._frame_num
486+
if isinstance(other._time, _FrameNumber):
487+
return other._time.value
483488
# If other has no frame_num, it must have a timecode. Convert to frames.
484489
return self._seconds_to_frames(other.seconds)
485490
raise TypeError("Cannot obtain frame number for this timecode.")
@@ -489,7 +494,7 @@ def __eq__(self, other: ty.Union[int, float, str, "FrameTimecode"]) -> bool:
489494
return False
490495
if _compare_as_fixed(self, other):
491496
return self.frame_num == other.frame_num
492-
if self._timecode or self._seconds is not None:
497+
if isinstance(self._time, (Timecode, _Seconds)):
493498
return self.seconds == self._get_other_as_seconds(other)
494499
return self.frame_num == self._get_other_as_frames(other)
495500

@@ -498,74 +503,76 @@ def __ne__(self, other: ty.Union[int, float, str, "FrameTimecode"]) -> bool:
498503
return True
499504
if _compare_as_fixed(self, other):
500505
return self.frame_num != other.frame_num
501-
if self._timecode or self._seconds is not None:
506+
if isinstance(self._time, (Timecode, _Seconds)):
502507
return self.seconds != self._get_other_as_seconds(other)
503508
return self.frame_num != self._get_other_as_frames(other)
504509

505510
def __lt__(self, other: ty.Union[int, float, str, "FrameTimecode"]) -> bool:
506511
if _compare_as_fixed(self, other):
507512
return self.frame_num < other.frame_num
508-
if self._timecode or self._seconds is not None:
513+
if isinstance(self._time, (Timecode, _Seconds)):
509514
return self.seconds < self._get_other_as_seconds(other)
510515
return self.frame_num < self._get_other_as_frames(other)
511516

512517
def __le__(self, other: ty.Union[int, float, str, "FrameTimecode"]) -> bool:
513518
if _compare_as_fixed(self, other):
514519
return self.frame_num <= other.frame_num
515-
if self._timecode or self._seconds is not None:
520+
if isinstance(self._time, (Timecode, _Seconds)):
516521
return self.seconds <= self._get_other_as_seconds(other)
517522
return self.frame_num <= self._get_other_as_frames(other)
518523

519524
def __gt__(self, other: ty.Union[int, float, str, "FrameTimecode"]) -> bool:
520525
if _compare_as_fixed(self, other):
521526
return self.frame_num > other.frame_num
522-
if self._timecode or self._seconds is not None:
527+
if isinstance(self._time, (Timecode, _Seconds)):
523528
return self.seconds > self._get_other_as_seconds(other)
524529
return self.frame_num > self._get_other_as_frames(other)
525530

526531
def __ge__(self, other: ty.Union[int, float, str, "FrameTimecode"]) -> bool:
527532
if _compare_as_fixed(self, other):
528533
return self.frame_num >= other.frame_num
529-
if self._timecode or self._seconds is not None:
534+
if isinstance(self._time, (Timecode, _Seconds)):
530535
return self.seconds >= self._get_other_as_seconds(other)
531536
return self.frame_num >= self._get_other_as_frames(other)
532537

533538
def __iadd__(self, other: ty.Union[int, float, str, "FrameTimecode"]) -> "FrameTimecode":
534-
other_has_timecode = isinstance(other, FrameTimecode) and other._timecode
539+
other_is_timecode = isinstance(other, FrameTimecode) and isinstance(other._time, Timecode)
535540

536-
if self._timecode and other_has_timecode:
537-
if self._timecode.time_base != other._timecode.time_base:
541+
if isinstance(self._time, Timecode) and other_is_timecode:
542+
if self._time.time_base != other._time.time_base:
538543
raise ValueError("timecodes have different time bases")
539-
self._timecode = Timecode(
540-
pts=max(0, self._timecode.pts + other._timecode.pts),
541-
time_base=self._timecode.time_base,
544+
self._time = Timecode(
545+
pts=max(0, self._time.pts + other._time.pts),
546+
time_base=self._time.time_base,
542547
)
543548
return self
544549

545550
# If either input is a timecode, the output shall also be one. The input which isn't a
546551
# timecode is converted into seconds, after which the equivalent timecode is computed.
547-
if self._timecode or other_has_timecode:
548-
timecode: Timecode = self._timecode if self._timecode else other._timecode
549-
seconds: float = self._get_other_as_seconds(other) if self._timecode else self.seconds
550-
self._timecode = Timecode(
552+
if isinstance(self._time, Timecode) or other_is_timecode:
553+
timecode: Timecode = self._time if isinstance(self._time, Timecode) else other._time
554+
seconds: float = (
555+
self._get_other_as_seconds(other)
556+
if isinstance(self._time, Timecode)
557+
else self.seconds
558+
)
559+
self._time = Timecode(
551560
pts=max(0, timecode.pts + round(seconds / timecode.time_base)),
552561
time_base=timecode.time_base,
553562
)
554-
self._seconds = None
555563
self._rate = None
556-
self._frame_num = None
557564
return self
558565

559-
other_has_seconds = isinstance(other, FrameTimecode) and other._seconds
560-
if self._seconds is not None and other_has_seconds:
561-
self._seconds = max(0, self._seconds + other._seconds)
566+
other_is_seconds = isinstance(other, FrameTimecode) and isinstance(other._time, _Seconds)
567+
if isinstance(self._time, _Seconds) and other_is_seconds:
568+
self._time = _Seconds(max(0, self._time.value + other._time.value))
562569
return self
563570

564-
if self._seconds is not None:
565-
self._seconds = max(0.0, self._seconds + self._get_other_as_seconds(other))
571+
if isinstance(self._time, _Seconds):
572+
self._time = _Seconds(max(0.0, self._time.value + self._get_other_as_seconds(other)))
566573
return self
567574

568-
self._frame_num = max(0, self._frame_num + self._get_other_as_frames(other))
575+
self._time = _FrameNumber(max(0, self._time.value + self._get_other_as_frames(other)))
569576
return self
570577

571578
def __add__(self, other: ty.Union[int, float, str, "FrameTimecode"]) -> "FrameTimecode":
@@ -574,41 +581,43 @@ def __add__(self, other: ty.Union[int, float, str, "FrameTimecode"]) -> "FrameTi
574581
return to_return
575582

576583
def __isub__(self, other: ty.Union[int, float, str, "FrameTimecode"]) -> "FrameTimecode":
577-
other_has_timecode = isinstance(other, FrameTimecode) and other._timecode
584+
other_is_timecode = isinstance(other, FrameTimecode) and isinstance(other._time, Timecode)
578585

579-
if self._timecode and other_has_timecode:
580-
if self._timecode.time_base != other._timecode.time_base:
586+
if isinstance(self._time, Timecode) and other_is_timecode:
587+
if self._time.time_base != other._time.time_base:
581588
raise ValueError("timecodes have different time bases")
582-
self._timecode = Timecode(
583-
pts=max(0, self._timecode.pts - other._timecode.pts),
584-
time_base=self._timecode.time_base,
589+
self._time = Timecode(
590+
pts=max(0, self._time.pts - other._time.pts),
591+
time_base=self._time.time_base,
585592
)
586593
return self
587594

588595
# If either input is a timecode, the output shall also be one. The input which isn't a
589596
# timecode is converted into seconds, after which the equivalent timecode is computed.
590-
if self._timecode or other_has_timecode:
591-
timecode: Timecode = self._timecode if self._timecode else other._timecode
592-
seconds: float = self._get_other_as_seconds(other) if self._timecode else self.seconds
593-
self._timecode = Timecode(
597+
if isinstance(self._time, Timecode) or other_is_timecode:
598+
timecode: Timecode = self._time if isinstance(self._time, Timecode) else other._time
599+
seconds: float = (
600+
self._get_other_as_seconds(other)
601+
if isinstance(self._time, Timecode)
602+
else self.seconds
603+
)
604+
self._time = Timecode(
594605
pts=max(0, timecode.pts - round(seconds / timecode.time_base)),
595606
time_base=timecode.time_base,
596607
)
597-
self._seconds = None
598608
self._rate = None
599-
self._frame_num = None
600609
return self
601610

602-
other_has_seconds = isinstance(other, FrameTimecode) and other._seconds
603-
if self._seconds is not None and other_has_seconds:
604-
self._seconds = max(0, self._seconds - other._seconds)
611+
other_is_seconds = isinstance(other, FrameTimecode) and isinstance(other._time, _Seconds)
612+
if isinstance(self._time, _Seconds) and other_is_seconds:
613+
self._time = _Seconds(max(0, self._time.value - other._time.value))
605614
return self
606615

607-
if self._seconds is not None:
608-
self._seconds = max(0.0, self._seconds - self._get_other_as_seconds(other))
616+
if isinstance(self._time, _Seconds):
617+
self._time = _Seconds(max(0.0, self._time.value - self._get_other_as_seconds(other)))
609618
return self
610619

611-
self._frame_num = max(0, self._frame_num - self._get_other_as_frames(other))
620+
self._time = _FrameNumber(max(0, self._time.value - self._get_other_as_frames(other)))
612621
return self
613622

614623
def __sub__(self, other: ty.Union[int, float, str, "FrameTimecode"]) -> "FrameTimecode":
@@ -620,7 +629,9 @@ def __sub__(self, other: ty.Union[int, float, str, "FrameTimecode"]) -> "FrameTi
620629
# need to use relevant property instead.
621630

622631
def __int__(self) -> int:
623-
return self._frame_num
632+
if isinstance(self._time, _FrameNumber):
633+
return self._time.value
634+
return self.frame_num
624635

625636
def __float__(self) -> float:
626637
return self.seconds
@@ -629,21 +640,21 @@ def __str__(self) -> str:
629640
return self.get_timecode()
630641

631642
def __repr__(self) -> str:
632-
if self._timecode:
633-
return f"{self.get_timecode()} [pts={self._timecode.pts}, time_base={self._timecode.time_base}]"
634-
if self._seconds is not None:
635-
return f"{self.get_timecode()} [seconds={self._seconds}, fps={self._rate}]"
636-
return f"{self.get_timecode()} [frame_num={self._frame_num}, fps={self._rate}]"
643+
if isinstance(self._time, Timecode):
644+
return f"{self.get_timecode()} [pts={self._time.pts}, time_base={self._time.time_base}]"
645+
if isinstance(self._time, _Seconds):
646+
return f"{self.get_timecode()} [seconds={self._time.value}, fps={self._rate}]"
647+
return f"{self.get_timecode()} [frame_num={self._time.value}, fps={self._rate}]"
637648

638649
def __hash__(self) -> int:
639-
if self._timecode:
640-
return hash(self._timecode)
641-
return self._frame_num
650+
if isinstance(self._time, Timecode):
651+
return hash(self._time)
652+
return self.frame_num
642653

643654
def _get_other_as_seconds(self, other: ty.Union[int, float, str, "FrameTimecode"]) -> float:
644655
"""Get the time in seconds from `other` for arithmetic operations."""
645656
if isinstance(other, int):
646-
if self._timecode:
657+
if isinstance(self._time, Timecode):
647658
# TODO(https://scenedetect.com/issue/168): We need to convert every place that uses
648659
# frame numbers with timestamps to convert to a non-frame based way of temporal
649660
# logic and instead use seconds-based.

0 commit comments

Comments
 (0)