70
70
71
71
import cv2
72
72
73
+ # TODO(https://scenedetect.com/issue/168): Ensure both CFR and VFR videos work as intended with this
74
+ # flag enabled. When this feature is stable, we can then work on a roll-out plan.
73
75
_USE_PTS_IN_DEVELOPMENT = False
74
76
75
77
##
@@ -176,12 +178,14 @@ def __init__(
176
178
self ._framerate = fps
177
179
self ._frame_num = None
178
180
self ._timecode : ty .Optional [Timecode ] = None
181
+ self ._seconds : ty .Optional [float ] = None
179
182
180
183
# Copy constructor.
181
184
if isinstance (timecode , FrameTimecode ):
182
185
self ._framerate = timecode ._framerate if fps is None else fps
183
186
self ._frame_num = timecode ._frame_num
184
187
self ._timecode = timecode ._timecode
188
+ self ._seconds = timecode ._seconds
185
189
return
186
190
187
191
# Timecode.
@@ -205,21 +209,27 @@ def __init__(
205
209
self ._framerate = float (fps )
206
210
# Process the timecode value, storing it as an exact number of frames.
207
211
if isinstance (timecode , str ):
208
- # TODO(v0.7): This will be incorrect for VFR videos. Need to represent this format
209
- # differently so we can support start/end times and min_scene_len correctly.
210
- self ._frame_num = self ._parse_timecode_string (timecode )
212
+ self ._seconds = self ._timecode_to_seconds (timecode )
213
+ self ._frame_num = self ._seconds_to_frames (self ._timecode_to_seconds (timecode ))
211
214
else :
212
215
self ._frame_num = self ._parse_timecode_number (timecode )
213
216
214
- # TODO(v0.7): Add a PTS property as well and slowly transition over to that, since we don't
215
- # always know the position as a "frame number". However, for the reverse case, we CAN state
216
- # the presentation time if we know the frame number (for a fixed framerate video).
217
217
@property
218
218
def frame_num (self ) -> ty .Optional [int ]:
219
+ if self ._timecode :
220
+ warnings .warn (
221
+ message = "TODO(https://scenedetect.com/issue/168): Update caller to handle VFR." ,
222
+ stacklevel = 2 ,
223
+ category = UserWarning ,
224
+ )
225
+ # We can calculate the approx. # of frames by taking the presentation time and the
226
+ # time base itself.
227
+ (num , den ) = (self ._timecode .time_base * self ._timecode .pts ).as_integer_ratio ()
228
+ return num / den
219
229
return self ._frame_num
220
230
221
231
@property
222
- def framerate (self ) -> ty .Optional [int ]:
232
+ def framerate (self ) -> ty .Optional [float ]:
223
233
return self ._framerate
224
234
225
235
def get_frames (self ) -> int :
@@ -250,7 +260,7 @@ def get_framerate(self) -> float:
250
260
)
251
261
return self .framerate
252
262
253
- # TODO(v0.7 ): Figure out how to deal with VFR here.
263
+ # TODO(https://scenedetect.com/issue/168 ): Figure out how to deal with VFR here.
254
264
def equal_framerate (self , fps ) -> bool :
255
265
"""Equal Framerate: Determines if the passed framerate is equal to that of this object.
256
266
@@ -261,14 +271,17 @@ def equal_framerate(self, fps) -> bool:
261
271
bool: True if passed fps matches the FrameTimecode object's framerate, False otherwise.
262
272
263
273
"""
264
- # TODO(v0.7): Support this comparison in the case FPS is not set but a timecode is.
274
+ # TODO(https://scenedetect.com/issue/168): Support this comparison in the case FPS is not
275
+ # set but a timecode is.
265
276
return math .fabs (self .framerate - fps ) < MAX_FPS_DELTA
266
277
267
278
@property
268
279
def seconds (self ) -> float :
269
280
"""The frame's position in number of seconds."""
270
281
if self ._timecode :
271
282
return self ._timecode .seconds
283
+ if self ._seconds :
284
+ return self ._seconds
272
285
# Assume constant framerate if we don't have timing information.
273
286
return float (self ._frame_num ) / self ._framerate
274
287
@@ -355,14 +368,13 @@ def _parse_timecode_number(self, timecode: ty.Union[int, float]) -> int:
355
368
else :
356
369
raise TypeError ("Timecode format/type unrecognized." )
357
370
358
- def _parse_timecode_string (self , input : str ) -> int :
359
- """Parses a string based on the three possible forms (in timecode format,
360
- as an integer number of frames, or floating-point seconds, ending with 's').
361
-
362
- Requires that the `framerate` property is set before calling this method.
363
- Assuming a framerate of 30.0 FPS, the strings '00:05:00.000', '00:05:00',
364
- '9000', '300s', and '300.0' are all possible valid values, all representing
365
- a period of time equal to 5 minutes, 300 seconds, or 9000 frames (at 30 FPS).
371
+ def _timecode_to_seconds (self , input : str ) -> float :
372
+ """Parses a string based on the three possible forms (in timecode format, as an integer
373
+ number of frames, or floating-point seconds, ending with 's'). Exact frame numbers (int)
374
+ requires the `framerate` property was set when the timecode was created. Assuming a
375
+ framerate of 30.0 FPS, the strings '00:05:00.000', '00:05:00', '9000', '300s', and
376
+ '300.0' are all possible valid values. These values represent periods of time equal to
377
+ 5 minutes, 300 seconds, or 9000 frames (at 30 FPS).
366
378
367
379
Raises:
368
380
ValueError: Value could not be parsed correctly.
@@ -374,7 +386,7 @@ def _parse_timecode_string(self, input: str) -> int:
374
386
timecode = int (input )
375
387
if timecode < 0 :
376
388
raise ValueError ("Timecode frame number must be positive." )
377
- return timecode
389
+ return timecode * self . framerate
378
390
# Timecode in string format 'HH:MM:SS[.nnn]' or 'MM:SS[.nnn]'
379
391
elif input .find (":" ) >= 0 :
380
392
values = input .split (":" )
@@ -392,7 +404,7 @@ def _parse_timecode_string(self, input: str) -> int:
392
404
if not (hrs >= 0 and mins >= 0 and secs >= 0 and mins < 60 and secs < 60 ):
393
405
raise ValueError ("Invalid timecode range (values outside allowed range)." )
394
406
secs += (hrs * 60 * 60 ) + (mins * 60 )
395
- return self . _seconds_to_frames ( secs )
407
+ return secs
396
408
# Try to parse the number as seconds in the format 1234.5 or 1234s
397
409
if input .endswith ("s" ):
398
410
input = input [:- 1 ]
@@ -401,7 +413,7 @@ def _parse_timecode_string(self, input: str) -> int:
401
413
as_float = float (input )
402
414
if as_float < 0.0 :
403
415
raise ValueError ("Timecode seconds value must be positive." )
404
- return self . _seconds_to_frames ( as_float )
416
+ return as_float
405
417
406
418
def _get_other_as_frames (self , other : ty .Union [int , float , str , "FrameTimecode" ]) -> int :
407
419
"""Get the frame number from `other` for arithmetic operations."""
@@ -410,7 +422,7 @@ def _get_other_as_frames(self, other: ty.Union[int, float, str, "FrameTimecode"]
410
422
if isinstance (other , float ):
411
423
return self ._seconds_to_frames (other )
412
424
if isinstance (other , str ):
413
- return self ._parse_timecode_string ( other )
425
+ return self ._seconds_to_frames ( self . _timecode_to_seconds ( other ) )
414
426
if isinstance (other , FrameTimecode ):
415
427
# If comparing two FrameTimecodes, they must have the same framerate for frame-based operations.
416
428
if self ._framerate and other ._framerate and not self .equal_framerate (other ._framerate ):
@@ -421,53 +433,73 @@ def _get_other_as_frames(self, other: ty.Union[int, float, str, "FrameTimecode"]
421
433
return other ._frame_num
422
434
# If other has no frame_num, it must have a timecode. Convert to frames.
423
435
return self ._seconds_to_frames (other .seconds )
424
- raise TypeError ("Unsupported type for performing arithmetic with FrameTimecode ." )
436
+ raise TypeError ("Cannot obtain frame number for this timecode ." )
425
437
426
438
def __eq__ (self , other : ty .Union [int , float , str , "FrameTimecode" ]) -> bool :
427
439
if other is None :
428
440
return False
429
- if self ._timecode :
441
+ if self ._timecode or self . _seconds is not None :
430
442
return self .seconds == self ._get_other_as_seconds (other )
431
443
return self .frame_num == self ._get_other_as_frames (other )
432
444
433
445
def __ne__ (self , other : ty .Union [int , float , str , "FrameTimecode" ]) -> bool :
434
446
if other is None :
435
447
return True
436
- if self ._timecode :
448
+ if self ._timecode or self . _seconds is not None :
437
449
return self .seconds != self ._get_other_as_seconds (other )
438
450
return self .frame_num != self ._get_other_as_frames (other )
439
451
440
452
def __lt__ (self , other : ty .Union [int , float , str , "FrameTimecode" ]) -> bool :
441
- if self ._timecode :
453
+ if self ._timecode or self . _seconds is not None :
442
454
return self .seconds < self ._get_other_as_seconds (other )
443
455
return self .frame_num < self ._get_other_as_frames (other )
444
456
445
457
def __le__ (self , other : ty .Union [int , float , str , "FrameTimecode" ]) -> bool :
446
- if self ._timecode :
458
+ if self ._timecode or self . _seconds is not None :
447
459
return self .seconds <= self ._get_other_as_seconds (other )
448
460
return self .frame_num <= self ._get_other_as_frames (other )
449
461
450
462
def __gt__ (self , other : ty .Union [int , float , str , "FrameTimecode" ]) -> bool :
451
- if self ._timecode :
463
+ if self ._timecode or self . _seconds is not None :
452
464
return self .seconds > self ._get_other_as_seconds (other )
453
465
return self .frame_num > self ._get_other_as_frames (other )
454
466
455
467
def __ge__ (self , other : ty .Union [int , float , str , "FrameTimecode" ]) -> bool :
456
- if self ._timecode :
468
+ if self ._timecode or self . _seconds is not None :
457
469
return self .seconds >= self ._get_other_as_seconds (other )
458
470
return self .frame_num >= self ._get_other_as_frames (other )
459
471
460
472
def __iadd__ (self , other : ty .Union [int , float , str , "FrameTimecode" ]) -> "FrameTimecode" :
461
- if self ._timecode :
462
- new_seconds = self .seconds + self ._get_other_as_seconds (other )
463
- # TODO: This is incorrect for VFR, need a better way to handle this.
464
- # For now, we convert back to a frame number.
465
- self ._frame_num = self ._seconds_to_frames (new_seconds )
466
- self ._timecode = None
467
- else :
468
- self ._frame_num += self ._get_other_as_frames (other )
469
- if self ._frame_num < 0 : # Required to allow adding negative seconds/frames.
470
- self ._frame_num = 0
473
+ other_has_timecode = isinstance (other , FrameTimecode ) and other ._timecode
474
+
475
+ if self ._timecode and other_has_timecode :
476
+ if self ._timecode .time_base != other ._timecode .time_base :
477
+ raise ValueError ("timecodes have different time bases" )
478
+ self ._timecode = Timecode (
479
+ pts = max (0 , self ._timecode .pts + other ._timecode .pts ),
480
+ time_base = self ._timecode .time_base ,
481
+ )
482
+ return self
483
+
484
+ # If either input is a timecode, the output shall also be one. The input which isn't a
485
+ # timecode is converted into seconds, after which the equivalent timecode is computed.
486
+ if self ._timecode or other_has_timecode :
487
+ timecode : Timecode = self ._timecode if self ._timecode else other ._timecode
488
+ seconds : float = self ._get_other_as_seconds (other ) if self ._timecode else self .seconds
489
+ self ._timecode = Timecode (
490
+ pts = max (0 , timecode .pts + round (seconds / timecode .time_base )),
491
+ time_base = timecode .time_base ,
492
+ )
493
+ self ._seconds = None
494
+ self ._framerate = None
495
+ self ._frame_num = None
496
+ return self
497
+
498
+ if self ._seconds and other ._seconds :
499
+ self ._seconds = max (0 , self ._seconds + other ._seconds )
500
+ return self
501
+
502
+ self ._frame_num = max (0 , self ._frame_num + self ._get_other_as_frames (other ))
471
503
return self
472
504
473
505
def __add__ (self , other : ty .Union [int , float , str , "FrameTimecode" ]) -> "FrameTimecode" :
@@ -476,16 +508,36 @@ def __add__(self, other: ty.Union[int, float, str, "FrameTimecode"]) -> "FrameTi
476
508
return to_return
477
509
478
510
def __isub__ (self , other : ty .Union [int , float , str , "FrameTimecode" ]) -> "FrameTimecode" :
479
- if self ._timecode :
480
- new_seconds = self .seconds - self ._get_other_as_seconds (other )
481
- # TODO: This is incorrect for VFR, need a better way to handle this.
482
- # For now, we convert back to a frame number.
483
- self ._frame_num = self ._seconds_to_frames (new_seconds )
484
- self ._timecode = None
485
- else :
486
- self ._frame_num -= self ._get_other_as_frames (other )
487
- if self ._frame_num < 0 :
488
- self ._frame_num = 0
511
+ other_has_timecode = isinstance (other , FrameTimecode ) and other ._timecode
512
+
513
+ if self ._timecode and other_has_timecode :
514
+ if self ._timecode .time_base != other ._timecode .time_base :
515
+ raise ValueError ("timecodes have different time bases" )
516
+ self ._timecode = Timecode (
517
+ pts = max (0 , self ._timecode .pts - other ._timecode .pts ),
518
+ time_base = self ._timecode .time_base ,
519
+ )
520
+ return self
521
+
522
+ # If either input is a timecode, the output shall also be one. The input which isn't a
523
+ # timecode is converted into seconds, after which the equivalent timecode is computed.
524
+ if self ._timecode or other_has_timecode :
525
+ timecode : Timecode = self ._timecode if self ._timecode else other ._timecode
526
+ seconds : float = self ._get_other_as_seconds (other ) if self ._timecode else self .seconds
527
+ self ._timecode = Timecode (
528
+ pts = max (0 , timecode .pts - round (seconds / timecode .time_base )),
529
+ time_base = timecode .time_base ,
530
+ )
531
+ self ._seconds = None
532
+ self ._framerate = None
533
+ self ._frame_num = None
534
+ return self
535
+
536
+ if self ._seconds and other ._seconds :
537
+ self ._seconds = max (0 , self ._seconds - other ._seconds )
538
+ return self
539
+
540
+ self ._frame_num = max (0 , self ._frame_num - self ._get_other_as_frames (other ))
489
541
return self
490
542
491
543
def __sub__ (self , other : ty .Union [int , float , str , "FrameTimecode" ]) -> "FrameTimecode" :
@@ -518,13 +570,18 @@ def __hash__(self) -> int:
518
570
def _get_other_as_seconds (self , other : ty .Union [int , float , str , "FrameTimecode" ]) -> float :
519
571
"""Get the time in seconds from `other` for arithmetic operations."""
520
572
if isinstance (other , int ):
573
+ if self ._timecode :
574
+ # TODO(https://scenedetect.com/issue/168): We need to convert every place that uses
575
+ # frame numbers with timestamps to convert to a non-frame based way of temporal
576
+ # logic and instead use seconds-based.
577
+ if _USE_PTS_IN_DEVELOPMENT and other == 1 :
578
+ return self .seconds
579
+ raise NotImplementedError ()
521
580
return float (other ) / self ._framerate
522
581
if isinstance (other , float ):
523
582
return other
524
583
if isinstance (other , str ):
525
- # This is not ideal, but we need a framerate to parse strings.
526
- # We create a temporary FrameTimecode to do this.
527
- return FrameTimecode (timecode = other , fps = self ._framerate ).seconds
584
+ return self ._timecode_to_seconds (other )
528
585
if isinstance (other , FrameTimecode ):
529
586
return other .seconds
530
587
raise TypeError ("Unsupported type for performing arithmetic with FrameTimecode." )
0 commit comments