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