1# -*- coding: utf-8 -*- 2# 3# PySceneDetect: Python-Based Video Scene Detector 4# --------------------------------------------------------------- 5# [ Site: http://www.bcastell.com/projects/PySceneDetect/ ] 6# [ Github: https://github.com/Breakthrough/PySceneDetect/ ] 7# [ Documentation: http://pyscenedetect.readthedocs.org/ ] 8# 9# Copyright (C) 2014-2020 Brandon Castellano <http://www.bcastell.com>. 10# 11# PySceneDetect is licensed under the BSD 3-Clause License; see the included 12# LICENSE file, or visit one of the following pages for details: 13# - https://github.com/Breakthrough/PySceneDetect/ 14# - http://www.bcastell.com/projects/PySceneDetect/ 15# 16# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 17# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 18# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 19# AUTHORS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN 20# ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION 21# WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 22# 23 24""" ``scenedetect.frame_timecode`` Module 25 26This module contains the :py:class:`FrameTimecode` object, which is used as a way for 27PySceneDetect to store frame-accurate timestamps of each cut. This is done by also 28specifying the video framerate with the timecode, allowing a frame number to be 29converted to/from a floating-point number of seconds, or string in the form 30`"HH:MM:SS[.nnn]"` where the `[.nnn]` part is optional. 31 32See the following examples, or the :py:class:`FrameTimecode constructor <FrameTimecode>`. 33 34Unit tests for the FrameTimecode object can be found in `tests/test_timecode.py`. 35""" 36 37# Standard Library Imports 38import math 39 40# PySceneDetect Library Imports 41from scenedetect.platform import STRING_TYPE 42 43 44MINIMUM_FRAMES_PER_SECOND_FLOAT = 1.0 / 1000.0 45MINIMUM_FRAMES_PER_SECOND_DELTA_FLOAT = 1.0 / 100000 46 47 48class FrameTimecode(object): 49 """ Object for frame-based timecodes, using the video framerate 50 to compute back and forth between frame number and second/timecode formats. 51 52 The timecode argument is valid only if it complies with one of the following 53 three types/formats: 54 55 1) string: standard timecode HH:MM:SS[.nnn]: 56 `str` in form 'HH:MM:SS' or 'HH:MM:SS.nnn', or 57 `list`/`tuple` in form [HH, MM, SS] or [HH, MM, SS.nnn] 58 2) float: number of seconds S[.SSS], where S >= 0.0: 59 `float` in form S.SSS, or 60 `str` in form 'Ss' or 'S.SSSs' (e.g. '5s', '1.234s') 61 3) int: Exact number of frames N, where N >= 0: 62 `int` in form `N`, or 63 `str` in form 'N' 64 65 Arguments: 66 timecode (str, float, int, or FrameTimecode): A timecode or frame 67 number, given in any of the above valid formats/types. This 68 argument is always required. 69 fps (float, or FrameTimecode, conditionally required): The framerate 70 to base all frame to time arithmetic on (if FrameTimecode, copied 71 from the passed framerate), to allow frame-accurate arithmetic. The 72 framerate must be the same when combining FrameTimecode objects 73 in operations. This argument is always required, unless **timecode** 74 is a FrameTimecode. 75 Raises: 76 TypeError: Thrown if timecode is wrong type/format, or if fps is None 77 or a type other than int or float. 78 ValueError: Thrown when specifying a negative timecode or framerate. 79 """ 80 81 def __init__(self, timecode=None, fps=None): 82 # type: (Union[int, float, str, FrameTimecode], float, 83 # Union[int, float, str, FrameTimecode]) 84 # The following two properties are what is used to keep track of time 85 # in a frame-specific manner. Note that once the framerate is set, 86 # the value should never be modified (only read if required). 87 self.framerate = None 88 self.frame_num = None 89 90 # Copy constructor. Only the timecode argument is used in this case. 91 if isinstance(timecode, FrameTimecode): 92 self.framerate = timecode.framerate 93 self.frame_num = timecode.frame_num 94 if fps is not None: 95 raise TypeError('Framerate cannot be overwritten when copying a FrameTimecode.') 96 else: 97 # Ensure other arguments are consistent with API. 98 if fps is None: 99 raise TypeError('Framerate (fps) is a required argument.') 100 if isinstance(fps, FrameTimecode): 101 fps = fps.framerate 102 103 # Process the given framerate, if it was not already set. 104 if not isinstance(fps, (int, float)): 105 raise TypeError('Framerate must be of type int/float.') 106 elif (isinstance(fps, int) and not fps > 0) or ( 107 isinstance(fps, float) and not fps >= MINIMUM_FRAMES_PER_SECOND_FLOAT): 108 raise ValueError('Framerate must be positive and greater than zero.') 109 self.framerate = float(fps) 110 111 # Process the timecode value, storing it as an exact number of frames. 112 if isinstance(timecode, (str, STRING_TYPE)): 113 self.frame_num = self._parse_timecode_string(timecode) 114 else: 115 self.frame_num = self._parse_timecode_number(timecode) 116 117 # Alternative formats under consideration (require unit tests before adding): 118 119 # Standard timecode in list format [HH, MM, SS.nnn] 120 #elif isinstance(timecode, (list, tuple)) and len(timecode) == 3: 121 # if any(not isinstance(x, (int, float)) for x in timecode): 122 # raise ValueError('Timecode components must be of type int/float.') 123 # hrs, mins, secs = timecode 124 # if not (hrs >= 0 and mins >= 0 and secs >= 0 and mins < 60 125 # and secs < 60): 126 # raise ValueError('Timecode components must be positive.') 127 # secs += (((hrs * 60.0) + mins) * 60.0) 128 # self.frame_num = int(secs * self.framerate) 129 130 131 def get_frames(self): 132 # type: () -> int 133 """ Get the current time/position in number of frames. This is the 134 equivalent of accessing the self.frame_num property (which, along 135 with the specified framerate, forms the base for all of the other 136 time measurement calculations, e.g. the :py:meth:`get_seconds` method). 137 138 If using to compare a :py:class:`FrameTimecode` with a frame number, 139 you can do so directly against the object (e.g. ``FrameTimecode(10, 10.0) <= 10``). 140 141 Returns: 142 int: The current time in frames (the current frame number). 143 """ 144 return int(self.frame_num) 145 146 147 def get_framerate(self): 148 # type: () -> float 149 """ Get Framerate: Returns the framerate used by the FrameTimecode object. 150 151 Returns: 152 float: Framerate of the current FrameTimecode object, in frames per second. 153 """ 154 return self.framerate 155 156 157 def equal_framerate(self, fps): 158 # type: (float) -> bool 159 """ Equal Framerate: Determines if the passed framerate is equal to that of the 160 FrameTimecode object. 161 162 Arguments: 163 fps: Framerate (float) to compare against within the precision constant 164 MINIMUM_FRAMES_PER_SECOND_DELTA_FLOAT defined in this module. 165 166 Returns: 167 bool: True if passed fps matches the FrameTimecode object's framerate, False otherwise. 168 169 """ 170 return math.fabs(self.framerate - fps) < MINIMUM_FRAMES_PER_SECOND_DELTA_FLOAT 171 172 173 def get_seconds(self): 174 # type: () -> float 175 """ Get the frame's position in number of seconds. 176 177 If using to compare a :py:class:`FrameTimecode` with a frame number, 178 you can do so directly against the object (e.g. ``FrameTimecode(10, 10.0) <= 1.0``). 179 180 Returns: 181 float: The current time/position in seconds. 182 """ 183 return float(self.frame_num) / self.framerate 184 185 186 def get_timecode(self, precision=3, use_rounding=True): 187 # type: (int, bool) -> str 188 """ Get a formatted timecode string of the form HH:MM:SS[.nnn]. 189 190 Args: 191 precision: The number of decimal places to include in the output ``[.nnn]``. 192 use_rounding: True (default) to round the output to the desired precision. 193 194 Returns: 195 str: The current time in the form ``"HH:MM:SS[.nnn]"``. 196 """ 197 # Compute hours and minutes based off of seconds, and update seconds. 198 secs = self.get_seconds() 199 base = 60.0 * 60.0 200 hrs = int(secs / base) 201 secs -= (hrs * base) 202 base = 60.0 203 mins = int(secs / base) 204 secs -= (mins * base) 205 # Convert seconds into string based on required precision. 206 if precision > 0: 207 if use_rounding: 208 secs = round(secs, precision) 209 #secs = math.ceil(secs * (10**precision)) / float(10**precision) 210 msec = format(secs, '.%df' % precision)[-precision:] 211 secs = '%02d.%s' % (int(secs), msec) 212 else: 213 secs = '%02d' % int(round(secs, 0)) if use_rounding else '%02d' % int(secs) 214 # Return hours, minutes, and seconds as a formatted timecode string. 215 return '%02d:%02d:%s' % (hrs, mins, secs) 216 217 def previous_frame(self): 218 # type: () -> FrameTimecode 219 """ 220 Returns a new FrameTimecode for the frame before this one. 221 :return: New FrameTimeCode object, one frame earlier 222 """ 223 new_timecode = FrameTimecode(self) 224 new_timecode.frame_num -= 1 225 return new_timecode 226 227 def _seconds_to_frames(self, seconds): 228 # type: (float) -> int 229 """ Converts the passed value seconds to the nearest number of frames using 230 the current FrameTimecode object's FPS (self.framerate). 231 232 Returns: 233 Integer number of frames the passed number of seconds represents using 234 the current FrameTimecode's framerate property. 235 """ 236 return int(seconds * self.framerate) 237 238 239 def _parse_timecode_number(self, timecode): 240 # type: (Union[int, float]) -> int 241 """ Parses a timecode number, storing it as the exact number of frames. 242 Can be passed as frame number (int), seconds (float) 243 244 Raises: 245 TypeError, ValueError 246 """ 247 # Process the timecode value, storing it as an exact number of frames. 248 # Exact number of frames N 249 if isinstance(timecode, int): 250 if timecode < 0: 251 raise ValueError('Timecode frame number must be positive and greater than zero.') 252 return timecode 253 # Number of seconds S 254 elif isinstance(timecode, float): 255 if timecode < 0.0: 256 raise ValueError('Timecode value must be positive and greater than zero.') 257 return self._seconds_to_frames(timecode) 258 # FrameTimecode 259 elif isinstance(timecode, FrameTimecode): 260 return timecode.frame_num 261 elif timecode is None: 262 raise TypeError('Timecode/frame number must be specified!') 263 else: 264 raise TypeError('Timecode format/type unrecognized.') 265 266 267 def _parse_timecode_string(self, timecode_string): 268 # type: (str) -> int 269 """ Parses a string based on the three possible forms (in timecode format, 270 as an integer number of frames, or floating-point seconds, ending with 's'). 271 Requires that the framerate property is set before calling this method. 272 Assuming a framerate of 30.0 FPS, the strings '00:05:00.000', '00:05:00', 273 '9000', '300s', and '300.0s' are all possible valid values, all representing 274 a period of time equal to 5 minutes, 300 seconds, or 9000 frames (at 30 FPS). 275 276 Raises: 277 TypeError, ValueError 278 """ 279 if self.framerate is None: 280 raise TypeError('self.framerate must be set before calling _parse_timecode_string.') 281 # Number of seconds S 282 if timecode_string.endswith('s'): 283 secs = timecode_string[:-1] 284 if not secs.replace('.', '').isdigit(): 285 raise ValueError('All characters in timecode seconds string must be digits.') 286 secs = float(secs) 287 if secs < 0.0: 288 raise ValueError('Timecode seconds value must be positive.') 289 return int(secs * self.framerate) 290 # Exact number of frames N 291 elif timecode_string.isdigit(): 292 timecode = int(timecode_string) 293 if timecode < 0: 294 raise ValueError('Timecode frame number must be positive.') 295 return timecode 296 # Standard timecode in string format 'HH:MM:SS[.nnn]' 297 else: 298 tc_val = timecode_string.split(':') 299 if not (len(tc_val) == 3 and tc_val[0].isdigit() and tc_val[1].isdigit() 300 and tc_val[2].replace('.', '').isdigit()): 301 raise ValueError('Unrecognized or improperly formatted timecode string.') 302 hrs, mins = int(tc_val[0]), int(tc_val[1]) 303 secs = float(tc_val[2]) if '.' in tc_val[2] else int(tc_val[2]) 304 if not (hrs >= 0 and mins >= 0 and secs >= 0 and mins < 60 and secs < 60): 305 raise ValueError('Invalid timecode range (values outside allowed range).') 306 secs += (((hrs * 60.0) + mins) * 60.0) 307 return int(secs * self.framerate) 308 309 310 def __iadd__(self, other): 311 # type: (Union[int, float, str, FrameTimecode]) -> FrameTimecode 312 if isinstance(other, int): 313 self.frame_num += other 314 elif isinstance(other, FrameTimecode): 315 if self.equal_framerate(other.framerate): 316 self.frame_num += other.frame_num 317 else: 318 raise ValueError('FrameTimecode instances require equal framerate for addition.') 319 # Check if value to add is in number of seconds. 320 elif isinstance(other, float): 321 self.frame_num += self._seconds_to_frames(other) 322 else: 323 raise TypeError('Unsupported type for performing addition with FrameTimecode.') 324 if self.frame_num < 0: # Required to allow adding negative seconds/frames. 325 self.frame_num = 0 326 return self 327 328 329 def __add__(self, other): 330 # type: (Union[int, float, str, FrameTimecode]) -> FrameTimecode 331 to_return = FrameTimecode(timecode=self) 332 to_return += other 333 return to_return 334 335 336 def __isub__(self, other): 337 # type: (Union[int, float, str, FrameTimecode]) -> FrameTimecode 338 if isinstance(other, int): 339 self.frame_num -= other 340 elif isinstance(other, FrameTimecode): 341 if self.equal_framerate(other.framerate): 342 self.frame_num -= other.frame_num 343 else: 344 raise ValueError('FrameTimecode instances require equal framerate for subtraction.') 345 # Check if value to add is in number of seconds. 346 elif isinstance(other, float): 347 self.frame_num -= self._seconds_to_frames(other) 348 else: 349 raise TypeError('Unsupported type for performing subtraction with FrameTimecode.') 350 if self.frame_num < 0: 351 self.frame_num = 0 352 return self 353 354 355 def __sub__(self, other): 356 # type: (Union[int, float, str, FrameTimecode]) -> FrameTimecode 357 to_return = FrameTimecode(timecode=self) 358 to_return -= other 359 return to_return 360 361 362 def __eq__(self, other): 363 # type: (Union[int, float, str, FrameTimecode]) -> bool 364 if isinstance(other, int): 365 return self.frame_num == other 366 elif isinstance(other, float): 367 return self.get_seconds() == other 368 elif isinstance(other, str): 369 return self.frame_num == self._parse_timecode_string(other) 370 elif isinstance(other, FrameTimecode): 371 if self.equal_framerate(other.framerate): 372 return self.frame_num == other.frame_num 373 else: 374 raise TypeError( 375 'FrameTimecode objects must have the same framerate to be compared.') 376 elif other is None: 377 return False 378 else: 379 raise TypeError('Unsupported type for performing == with FrameTimecode.') 380 381 382 def __ne__(self, other): 383 # type: (Union[int, float, str, FrameTimecode]) -> bool 384 return not self == other 385 386 387 def __lt__(self, other): 388 # type: (Union[int, float, str, FrameTimecode]) -> bool 389 if isinstance(other, int): 390 return self.frame_num < other 391 elif isinstance(other, float): 392 return self.get_seconds() < other 393 elif isinstance(other, str): 394 return self.frame_num < self._parse_timecode_string(other) 395 elif isinstance(other, FrameTimecode): 396 if self.equal_framerate(other.framerate): 397 return self.frame_num < other.frame_num 398 else: 399 raise TypeError( 400 'FrameTimecode objects must have the same framerate to be compared.') 401 #elif other is None: 402 # return False 403 else: 404 raise TypeError('Unsupported type for performing < with FrameTimecode.') 405 406 407 def __le__(self, other): 408 # type: (Union[int, float, str, FrameTimecode]) -> bool 409 if isinstance(other, int): 410 return self.frame_num <= other 411 elif isinstance(other, float): 412 return self.get_seconds() <= other 413 elif isinstance(other, str): 414 return self.frame_num <= self._parse_timecode_string(other) 415 elif isinstance(other, FrameTimecode): 416 if self.equal_framerate(other.framerate): 417 return self.frame_num <= other.frame_num 418 else: 419 raise TypeError( 420 'FrameTimecode objects must have the same framerate to be compared.') 421 #elif other is None: 422 # return False 423 else: 424 raise TypeError('Unsupported type for performing <= with FrameTimecode.') 425 426 427 def __gt__(self, other): 428 # type: (Union[int, float, str, FrameTimecode]) -> bool 429 if isinstance(other, int): 430 return self.frame_num > other 431 elif isinstance(other, float): 432 return self.get_seconds() > other 433 elif isinstance(other, str): 434 return self.frame_num > self._parse_timecode_string(other) 435 elif isinstance(other, FrameTimecode): 436 if self.equal_framerate(other.framerate): 437 return self.frame_num > other.frame_num 438 else: 439 raise TypeError( 440 'FrameTimecode objects must have the same framerate to be compared.') 441 #elif other is None: 442 # return False 443 else: 444 raise TypeError('Unsupported type (%s) for performing > with FrameTimecode.' % 445 type(other).__name__) 446 447 448 def __ge__(self, other): 449 # type: (Union[int, float, str, FrameTimecode]) -> bool 450 if isinstance(other, int): 451 return self.frame_num >= other 452 elif isinstance(other, float): 453 return self.get_seconds() >= other 454 elif isinstance(other, str): 455 return self.frame_num >= self._parse_timecode_string(other) 456 elif isinstance(other, FrameTimecode): 457 if self.equal_framerate(other.framerate): 458 return self.frame_num >= other.frame_num 459 else: 460 raise TypeError( 461 'FrameTimecode objects must have the same framerate to be compared.') 462 #elif other is None: 463 # return False 464 else: 465 raise TypeError('Unsupported type for performing >= with FrameTimecode.') 466 467 468 469 def __int__(self): 470 return self.frame_num 471 472 def __float__(self): 473 return self.get_seconds() 474 475 def __str__(self): 476 return self.get_timecode() 477 478 def __repr__(self): 479 return 'FrameTimecode(frame=%d, fps=%f)' % (self.frame_num, self.framerate) 480