frame_timecode.py 20 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458
  1. # -*- coding: utf-8 -*-
  2. #
  3. # PySceneDetect: Python-Based Video Scene Detector
  4. # -------------------------------------------------------------------
  5. # [ Site: https://scenedetect.com ]
  6. # [ Docs: https://scenedetect.com/docs/ ]
  7. # [ Github: https://github.com/Breakthrough/PySceneDetect/ ]
  8. #
  9. # Copyright (C) 2014-2024 Brandon Castellano <http://www.bcastell.com>.
  10. # PySceneDetect is licensed under the BSD 3-Clause License; see the
  11. # included LICENSE file, or visit one of the above pages for details.
  12. #
  13. """``scenedetect.frame_timecode`` Module
  14. This module implements :class:`FrameTimecode` which is used as a way for PySceneDetect to store
  15. frame-accurate timestamps of each cut. This is done by also specifying the video framerate with the
  16. timecode, allowing a frame number to be converted to/from a floating-point number of seconds, or
  17. string in the form `"HH:MM:SS[.nnn]"` where the `[.nnn]` part is optional.
  18. See the following examples, or the :class:`FrameTimecode constructor <FrameTimecode>`.
  19. ===============================================================
  20. Usage Examples
  21. ===============================================================
  22. A :class:`FrameTimecode` can be created by specifying a timecode (`int` for number of frames,
  23. `float` for number of seconds, or `str` in the form "HH:MM:SS" or "HH:MM:SS.nnn") with a framerate:
  24. .. code:: python
  25. frames = FrameTimecode(timecode = 29, fps = 29.97)
  26. seconds_float = FrameTimecode(timecode = 10.0, fps = 10.0)
  27. timecode_str = FrameTimecode(timecode = "00:00:10.000", fps = 10.0)
  28. Arithmetic/comparison operations with :class:`FrameTimecode` objects is also possible, and the
  29. other operand can also be of the above types:
  30. .. code:: python
  31. x = FrameTimecode(timecode = "00:01:00.000", fps = 10.0)
  32. # Can add int (frames), float (seconds), or str (timecode).
  33. print(x + 10)
  34. print(x + 10.0)
  35. print(x + "00:10:00")
  36. # Same for all comparison operators.
  37. print((x + 10.0) == "00:01:10.000")
  38. :class:`FrameTimecode` objects can be added and subtracted, however the current implementation
  39. disallows negative values, and will clamp negative results to 0.
  40. .. warning::
  41. Be careful when subtracting :class:`FrameTimecode` objects or adding negative
  42. amounts of frames/seconds. In the example below, ``c`` will be at frame 0 since
  43. ``b > a``, but ``d`` will be at frame 5:
  44. .. code:: python
  45. a = FrameTimecode(5, 10.0)
  46. b = FrameTimecode(10, 10.0)
  47. c = a - b # b > a, so c == 0
  48. d = b - a
  49. assert(c == 0)
  50. assert(d == 5)
  51. """
  52. import math
  53. from typing import Union
  54. MAX_FPS_DELTA: float = 1.0 / 100000
  55. """Maximum amount two framerates can differ by for equality testing."""
  56. _SECONDS_PER_MINUTE = 60.0
  57. _SECONDS_PER_HOUR = 60.0 * _SECONDS_PER_MINUTE
  58. _MINUTES_PER_HOUR = 60.0
  59. class FrameTimecode:
  60. """Object for frame-based timecodes, using the video framerate to compute back and
  61. forth between frame number and seconds/timecode.
  62. A timecode is valid only if it complies with one of the following three types/formats:
  63. 1. Timecode as `str` in the form "HH:MM:SS[.nnn]" (`"01:23:45"` or `"01:23:45.678"`)
  64. 2. Number of seconds as `float`, or `str` in form "SSSS.nnnn" (`"45.678"`)
  65. 3. Exact number of frames as `int`, or `str` in form NNNNN (`456` or `"456"`)
  66. """
  67. def __init__(self,
  68. timecode: Union[int, float, str, 'FrameTimecode'] = None,
  69. fps: Union[int, float, str, 'FrameTimecode'] = None):
  70. """
  71. Arguments:
  72. timecode: A frame number (int), number of seconds (float), or timecode (str in
  73. the form `'HH:MM:SS'` or `'HH:MM:SS.nnn'`).
  74. fps: The framerate or FrameTimecode to use as a time base for all arithmetic.
  75. Raises:
  76. TypeError: Thrown if either `timecode` or `fps` are unsupported types.
  77. ValueError: Thrown when specifying a negative timecode or framerate.
  78. """
  79. # The following two properties are what is used to keep track of time
  80. # in a frame-specific manner. Note that once the framerate is set,
  81. # the value should never be modified (only read if required).
  82. # TODO(v1.0): Make these actual @properties.
  83. self.framerate = None
  84. self.frame_num = None
  85. # Copy constructor. Only the timecode argument is used in this case.
  86. if isinstance(timecode, FrameTimecode):
  87. self.framerate = timecode.framerate
  88. self.frame_num = timecode.frame_num
  89. if fps is not None:
  90. raise TypeError('Framerate cannot be overwritten when copying a FrameTimecode.')
  91. else:
  92. # Ensure other arguments are consistent with API.
  93. if fps is None:
  94. raise TypeError('Framerate (fps) is a required argument.')
  95. if isinstance(fps, FrameTimecode):
  96. fps = fps.framerate
  97. # Process the given framerate, if it was not already set.
  98. if not isinstance(fps, (int, float)):
  99. raise TypeError('Framerate must be of type int/float.')
  100. if (isinstance(fps, int) and not fps > 0) or (isinstance(fps, float)
  101. and not fps >= MAX_FPS_DELTA):
  102. raise ValueError('Framerate must be positive and greater than zero.')
  103. self.framerate = float(fps)
  104. # Process the timecode value, storing it as an exact number of frames.
  105. if isinstance(timecode, str):
  106. self.frame_num = self._parse_timecode_string(timecode)
  107. else:
  108. self.frame_num = self._parse_timecode_number(timecode)
  109. # TODO(v1.0): Add a `frame` property to replace the existing one and deprecate this getter.
  110. def get_frames(self) -> int:
  111. """Get the current time/position in number of frames. This is the
  112. equivalent of accessing the self.frame_num property (which, along
  113. with the specified framerate, forms the base for all of the other
  114. time measurement calculations, e.g. the :meth:`get_seconds` method).
  115. If using to compare a :class:`FrameTimecode` with a frame number,
  116. you can do so directly against the object (e.g. ``FrameTimecode(10, 10.0) <= 10``).
  117. Returns:
  118. int: The current time in frames (the current frame number).
  119. """
  120. return self.frame_num
  121. # TODO(v1.0): Add a `framerate` property to replace the existing one and deprecate this getter.
  122. def get_framerate(self) -> float:
  123. """Get Framerate: Returns the framerate used by the FrameTimecode object.
  124. Returns:
  125. float: Framerate of the current FrameTimecode object, in frames per second.
  126. """
  127. return self.framerate
  128. def equal_framerate(self, fps) -> bool:
  129. """Equal Framerate: Determines if the passed framerate is equal to that of this object.
  130. Arguments:
  131. fps: Framerate to compare against within the precision constant defined in this module
  132. (see :data:`MAX_FPS_DELTA`).
  133. Returns:
  134. bool: True if passed fps matches the FrameTimecode object's framerate, False otherwise.
  135. """
  136. return math.fabs(self.framerate - fps) < MAX_FPS_DELTA
  137. # TODO(v1.0): Add a `seconds` property to replace this and deprecate the existing one.
  138. def get_seconds(self) -> float:
  139. """Get the frame's position in number of seconds.
  140. If using to compare a :class:`FrameTimecode` with a frame number,
  141. you can do so directly against the object (e.g. ``FrameTimecode(10, 10.0) <= 1.0``).
  142. Returns:
  143. float: The current time/position in seconds.
  144. """
  145. return float(self.frame_num) / self.framerate
  146. # TODO(v1.0): Add a `timecode` property to replace this and deprecate the existing one.
  147. def get_timecode(self, precision: int = 3, use_rounding: bool = True) -> str:
  148. """Get a formatted timecode string of the form HH:MM:SS[.nnn].
  149. Args:
  150. precision: The number of decimal places to include in the output ``[.nnn]``.
  151. use_rounding: Rounds the output to the desired precision. If False, the value
  152. will be truncated to the specified precision.
  153. Returns:
  154. str: The current time in the form ``"HH:MM:SS[.nnn]"``.
  155. """
  156. # Compute hours and minutes based off of seconds, and update seconds.
  157. secs = self.get_seconds()
  158. hrs = int(secs / _SECONDS_PER_HOUR)
  159. secs -= (hrs * _SECONDS_PER_HOUR)
  160. mins = int(secs / _SECONDS_PER_MINUTE)
  161. secs = max(0.0, secs - (mins * _SECONDS_PER_MINUTE))
  162. if use_rounding:
  163. secs = round(secs, precision)
  164. secs = min(_SECONDS_PER_MINUTE, secs)
  165. # Guard against emitting timecodes with 60 seconds after rounding/floating point errors.
  166. if int(secs) == _SECONDS_PER_MINUTE:
  167. secs = 0.0
  168. mins += 1
  169. if mins >= _MINUTES_PER_HOUR:
  170. mins = 0
  171. hrs += 1
  172. # We have to extend the precision by 1 here, since `format` will round up.
  173. msec = format(secs, '.%df' % (precision + 1)) if precision else ''
  174. # Need to include decimal place in `msec_str`.
  175. msec_str = msec[-(2 + precision):-1]
  176. secs_str = f"{int(secs):02d}{msec_str}"
  177. # Return hours, minutes, and seconds as a formatted timecode string.
  178. return '%02d:%02d:%s' % (hrs, mins, secs_str)
  179. # TODO(v1.0): Add a `previous` property to replace the existing one and deprecate this getter.
  180. def previous_frame(self) -> 'FrameTimecode':
  181. """Return a new FrameTimecode for the previous frame (or 0 if on frame 0)."""
  182. new_timecode = FrameTimecode(self)
  183. new_timecode.frame_num = max(0, new_timecode.frame_num - 1)
  184. return new_timecode
  185. def _seconds_to_frames(self, seconds: float) -> int:
  186. """Convert the passed value seconds to the nearest number of frames using
  187. the current FrameTimecode object's FPS (self.framerate).
  188. Returns:
  189. Integer number of frames the passed number of seconds represents using
  190. the current FrameTimecode's framerate property.
  191. """
  192. return round(seconds * self.framerate)
  193. def _parse_timecode_number(self, timecode: Union[int, float]) -> int:
  194. """ Parse a timecode number, storing it as the exact number of frames.
  195. Can be passed as frame number (int), seconds (float)
  196. Raises:
  197. TypeError, ValueError
  198. """
  199. # Process the timecode value, storing it as an exact number of frames.
  200. # Exact number of frames N
  201. if isinstance(timecode, int):
  202. if timecode < 0:
  203. raise ValueError('Timecode frame number must be positive and greater than zero.')
  204. return timecode
  205. # Number of seconds S
  206. elif isinstance(timecode, float):
  207. if timecode < 0.0:
  208. raise ValueError('Timecode value must be positive and greater than zero.')
  209. return self._seconds_to_frames(timecode)
  210. # FrameTimecode
  211. elif isinstance(timecode, FrameTimecode):
  212. return timecode.frame_num
  213. elif timecode is None:
  214. raise TypeError('Timecode/frame number must be specified!')
  215. else:
  216. raise TypeError('Timecode format/type unrecognized.')
  217. def _parse_timecode_string(self, input: str) -> int:
  218. """Parses a string based on the three possible forms (in timecode format,
  219. as an integer number of frames, or floating-point seconds, ending with 's').
  220. Requires that the `framerate` property is set before calling this method.
  221. Assuming a framerate of 30.0 FPS, the strings '00:05:00.000', '00:05:00',
  222. '9000', '300s', and '300.0' are all possible valid values, all representing
  223. a period of time equal to 5 minutes, 300 seconds, or 9000 frames (at 30 FPS).
  224. Raises:
  225. ValueError: Value could not be parsed correctly.
  226. """
  227. assert not self.framerate is None
  228. input = input.strip()
  229. # Exact number of frames N
  230. if input.isdigit():
  231. timecode = int(input)
  232. if timecode < 0:
  233. raise ValueError('Timecode frame number must be positive.')
  234. return timecode
  235. # Timecode in string format 'HH:MM:SS[.nnn]'
  236. elif input.find(":") >= 0:
  237. values = input.split(":")
  238. hrs, mins = int(values[0]), int(values[1])
  239. secs = float(values[2]) if '.' in values[2] else int(values[2])
  240. if not (hrs >= 0 and mins >= 0 and secs >= 0 and mins < 60 and secs < 60):
  241. raise ValueError('Invalid timecode range (values outside allowed range).')
  242. secs += (hrs * 60 * 60) + (mins * 60)
  243. return self._seconds_to_frames(secs)
  244. # Try to parse the number as seconds in the format 1234.5 or 1234s
  245. if input.endswith('s'):
  246. input = input[:-1]
  247. if not input.replace('.', '').isdigit():
  248. raise ValueError('All characters in timecode seconds string must be digits.')
  249. as_float = float(input)
  250. if as_float < 0.0:
  251. raise ValueError('Timecode seconds value must be positive.')
  252. return self._seconds_to_frames(as_float)
  253. def __iadd__(self, other: Union[int, float, str, 'FrameTimecode']) -> 'FrameTimecode':
  254. if isinstance(other, int):
  255. self.frame_num += other
  256. elif isinstance(other, FrameTimecode):
  257. if self.equal_framerate(other.framerate):
  258. self.frame_num += other.frame_num
  259. else:
  260. raise ValueError('FrameTimecode instances require equal framerate for addition.')
  261. # Check if value to add is in number of seconds.
  262. elif isinstance(other, float):
  263. self.frame_num += self._seconds_to_frames(other)
  264. elif isinstance(other, str):
  265. self.frame_num += self._parse_timecode_string(other)
  266. else:
  267. raise TypeError('Unsupported type for performing addition with FrameTimecode.')
  268. if self.frame_num < 0: # Required to allow adding negative seconds/frames.
  269. self.frame_num = 0
  270. return self
  271. def __add__(self, other: Union[int, float, str, 'FrameTimecode']) -> 'FrameTimecode':
  272. to_return = FrameTimecode(timecode=self)
  273. to_return += other
  274. return to_return
  275. def __isub__(self, other: Union[int, float, str, 'FrameTimecode']) -> 'FrameTimecode':
  276. if isinstance(other, int):
  277. self.frame_num -= other
  278. elif isinstance(other, FrameTimecode):
  279. if self.equal_framerate(other.framerate):
  280. self.frame_num -= other.frame_num
  281. else:
  282. raise ValueError('FrameTimecode instances require equal framerate for subtraction.')
  283. # Check if value to add is in number of seconds.
  284. elif isinstance(other, float):
  285. self.frame_num -= self._seconds_to_frames(other)
  286. elif isinstance(other, str):
  287. self.frame_num -= self._parse_timecode_string(other)
  288. else:
  289. raise TypeError('Unsupported type for performing subtraction with FrameTimecode: %s' %
  290. type(other))
  291. if self.frame_num < 0:
  292. self.frame_num = 0
  293. return self
  294. def __sub__(self, other: Union[int, float, str, 'FrameTimecode']) -> 'FrameTimecode':
  295. to_return = FrameTimecode(timecode=self)
  296. to_return -= other
  297. return to_return
  298. def __eq__(self, other: Union[int, float, str, 'FrameTimecode']) -> 'FrameTimecode':
  299. if isinstance(other, int):
  300. return self.frame_num == other
  301. elif isinstance(other, float):
  302. return self.get_seconds() == other
  303. elif isinstance(other, str):
  304. return self.frame_num == self._parse_timecode_string(other)
  305. elif isinstance(other, FrameTimecode):
  306. if self.equal_framerate(other.framerate):
  307. return self.frame_num == other.frame_num
  308. else:
  309. raise TypeError(
  310. 'FrameTimecode objects must have the same framerate to be compared.')
  311. elif other is None:
  312. return False
  313. else:
  314. raise TypeError('Unsupported type for performing == with FrameTimecode: %s' %
  315. type(other))
  316. def __ne__(self, other: Union[int, float, str, 'FrameTimecode']) -> bool:
  317. return not self == other
  318. def __lt__(self, other: Union[int, float, str, 'FrameTimecode']) -> bool:
  319. if isinstance(other, int):
  320. return self.frame_num < other
  321. elif isinstance(other, float):
  322. return self.get_seconds() < other
  323. elif isinstance(other, str):
  324. return self.frame_num < self._parse_timecode_string(other)
  325. elif isinstance(other, FrameTimecode):
  326. if self.equal_framerate(other.framerate):
  327. return self.frame_num < other.frame_num
  328. else:
  329. raise TypeError(
  330. 'FrameTimecode objects must have the same framerate to be compared.')
  331. else:
  332. raise TypeError('Unsupported type for performing < with FrameTimecode: %s' %
  333. type(other))
  334. def __le__(self, other: Union[int, float, str, 'FrameTimecode']) -> bool:
  335. if isinstance(other, int):
  336. return self.frame_num <= other
  337. elif isinstance(other, float):
  338. return self.get_seconds() <= other
  339. elif isinstance(other, str):
  340. return self.frame_num <= self._parse_timecode_string(other)
  341. elif isinstance(other, FrameTimecode):
  342. if self.equal_framerate(other.framerate):
  343. return self.frame_num <= other.frame_num
  344. else:
  345. raise TypeError(
  346. 'FrameTimecode objects must have the same framerate to be compared.')
  347. else:
  348. raise TypeError('Unsupported type for performing <= with FrameTimecode: %s' %
  349. type(other))
  350. def __gt__(self, other: Union[int, float, str, 'FrameTimecode']) -> bool:
  351. if isinstance(other, int):
  352. return self.frame_num > other
  353. elif isinstance(other, float):
  354. return self.get_seconds() > other
  355. elif isinstance(other, str):
  356. return self.frame_num > self._parse_timecode_string(other)
  357. elif isinstance(other, FrameTimecode):
  358. if self.equal_framerate(other.framerate):
  359. return self.frame_num > other.frame_num
  360. else:
  361. raise TypeError(
  362. 'FrameTimecode objects must have the same framerate to be compared.')
  363. else:
  364. raise TypeError('Unsupported type for performing > with FrameTimecode: %s' %
  365. type(other))
  366. def __ge__(self, other: Union[int, float, str, 'FrameTimecode']) -> bool:
  367. if isinstance(other, int):
  368. return self.frame_num >= other
  369. elif isinstance(other, float):
  370. return self.get_seconds() >= other
  371. elif isinstance(other, str):
  372. return self.frame_num >= self._parse_timecode_string(other)
  373. elif isinstance(other, FrameTimecode):
  374. if self.equal_framerate(other.framerate):
  375. return self.frame_num >= other.frame_num
  376. else:
  377. raise TypeError(
  378. 'FrameTimecode objects must have the same framerate to be compared.')
  379. else:
  380. raise TypeError('Unsupported type for performing >= with FrameTimecode: %s' %
  381. type(other))
  382. # TODO(v1.0): __int__ and __float__ should be removed. Mark as deprecated, and indicate
  383. # need to use relevant property instead.
  384. def __int__(self) -> int:
  385. return self.frame_num
  386. def __float__(self) -> float:
  387. return self.get_seconds()
  388. def __str__(self) -> str:
  389. return self.get_timecode()
  390. def __repr__(self) -> str:
  391. return '%s [frame=%d, fps=%.3f]' % (self.get_timecode(), self.frame_num, self.framerate)
  392. def __hash__(self) -> int:
  393. return self.frame_num