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