Skip to content

Commit 4b9edcc

Browse files
committed
[timecode] Add new timecode module and initial VFR support #168
Add new Timecode type to replace FrameTimecode. The new type supports VFR by storing exact timing information. Rough out how support for this will look in VideoStream by using the iterator protocol. This is only implemented for OpenCV and PyAV currently, and both have some minor drawbacks.
1 parent 485e561 commit 4b9edcc

6 files changed

Lines changed: 138 additions & 7 deletions

File tree

scenedetect/__init__.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -56,7 +56,7 @@
5656

5757
# Used for module identification and when printing version & about info
5858
# (e.g. calling `scenedetect version` or `scenedetect about`).
59-
__version__ = "0.6.6"
59+
__version__ = "0.7-dev0"
6060

6161
init_logger()
6262
logger = getLogger("pyscenedetect")

scenedetect/backends/opencv.py

Lines changed: 33 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -20,14 +20,22 @@
2020
import math
2121
import os.path
2222
import typing as ty
23+
from fractions import Fraction
2324
from logging import getLogger
2425

2526
import cv2
2627
import numpy as np
2728

2829
from scenedetect.frame_timecode import MAX_FPS_DELTA, FrameTimecode
2930
from scenedetect.platform import get_file_name
30-
from scenedetect.video_stream import FrameRateUnavailable, SeekError, VideoOpenFailure, VideoStream
31+
from scenedetect.timecode import Timecode
32+
from scenedetect.video_stream import (
33+
FrameRateUnavailable,
34+
SeekError,
35+
VideoFrame,
36+
VideoOpenFailure,
37+
VideoStream,
38+
)
3139

3240
logger = getLogger("pyscenedetect")
3341

@@ -264,6 +272,30 @@ def reset(self):
264272
self._cap.release()
265273
self._open_capture(self._frame_rate)
266274

275+
def __next__(self):
276+
# NOTE: POS_FRAMES starts from 0 before any frames are read.
277+
read, image = self._cap.read()
278+
if not read:
279+
raise StopIteration()
280+
# We can only query CAP_PROP_PTS if this uses the ffmpeg backend, however it doesn't seem
281+
# to work correctly. Quite frequently consecutive frames return the same PTS. We might need
282+
# to just abandon using PTS with OpenCV and rely on milliseconds. This will still result
283+
# in occasional off-by-one errors for VFR videos, but better than the status quo.
284+
#
285+
# We should also add a config option so users can specify if OpenCV should use fixed or
286+
# variable timing (i.e. if we should use CAP_PROP_POS_MSEC or CAP_PROP_POS_FRAMES for
287+
# timestamp calculation).
288+
USE_PTS = False
289+
if USE_PTS:
290+
pts = self._cap.get(cv2.CAP_PROP_PTS)
291+
time_base = Fraction.from_float(self._cap.get(cv2.CAP_PROP_FPS))
292+
time_base = Fraction(numerator=time_base.denominator, denominator=time_base.numerator)
293+
else:
294+
pts = self._cap.get(cv2.CAP_PROP_POS_MSEC)
295+
time_base = Fraction(1, 1000)
296+
timecode = Timecode(pts=round(pts), time_base=time_base)
297+
return VideoFrame(image=image, timecode=timecode)
298+
267299
def read(self, decode: bool = True, advance: bool = True) -> ty.Union[np.ndarray, bool]:
268300
"""Read and decode the next frame as a np.ndarray. Returns False when video ends,
269301
or the maximum number of decode attempts has passed.

scenedetect/backends/pyav.py

Lines changed: 15 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -19,7 +19,8 @@
1919

2020
from scenedetect.frame_timecode import MAX_FPS_DELTA, FrameTimecode
2121
from scenedetect.platform import get_file_name
22-
from scenedetect.video_stream import FrameRateUnavailable, VideoOpenFailure, VideoStream
22+
from scenedetect.timecode import Timecode
23+
from scenedetect.video_stream import FrameRateUnavailable, VideoFrame, VideoOpenFailure, VideoStream
2324

2425
logger = getLogger("pyscenedetect")
2526

@@ -263,6 +264,19 @@ def reset(self):
263264
except Exception as ex:
264265
raise VideoOpenFailure() from ex
265266

267+
def __next__(self) -> VideoFrame:
268+
# TODO: On the VFR test video, we seem to only decode 1979 frames instead of 1980. See what
269+
# the issue could be.
270+
try:
271+
frame = next(self._container.decode(video=0))
272+
except av.error.EOFError as ex:
273+
if not self._handle_eof():
274+
raise StopIteration() from ex
275+
return next(self) # *NOTE*: self._handle_eof must ensure we won't recurse again.
276+
image = frame.to_ndarray(format="bgr24")
277+
timecode = Timecode(pts=frame.pts, time_base=frame.time_base)
278+
return VideoFrame(image=image, timecode=timecode)
279+
266280
def read(self, decode: bool = True, advance: bool = True) -> ty.Union[np.ndarray, bool]:
267281
"""Read and decode the next frame as a np.ndarray. Returns False when video ends.
268282

scenedetect/timecode.py

Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,49 @@
1+
#
2+
# PySceneDetect: Python-Based Video Scene Detector
3+
# -------------------------------------------------------------------
4+
# [ Site: https://scenedetect.com ]
5+
# [ Docs: https://scenedetect.com/docs/ ]
6+
# [ Github: https://github.com/Breakthrough/PySceneDetect/ ]
7+
#
8+
# Copyright (C) 2014-2025 Brandon Castellano <http://www.bcastell.com>.
9+
# PySceneDetect is licensed under the BSD 3-Clause License; see the
10+
# included LICENSE file, or visit one of the above pages for details.
11+
#
12+
"""``scenedetect.timecode`` Module
13+
14+
This module contains types and functions for handling video timecodes, including parsing user input
15+
and timecode format conversion.
16+
"""
17+
18+
from dataclasses import dataclass
19+
from fractions import Fraction
20+
21+
22+
# TODO(@Breakthrough): Add conversion from Timecode -> FrameTimecode for backwards compatibility.
23+
# TODO(@Breakthrough): How should we deal with frame numbers? We might need to detect if a video is
24+
# VFR or not, and if so, either omit them or always start them from 0 regardless of the start seek.
25+
# With PyAV we can probably assume the video is VFR if the guessed rate of the stream differs
26+
# from the average rate.
27+
#
28+
# Each backend has slight nuances we have to take into account:
29+
# - PyAV: Does not include a position in frames, we can probably estimate it. Need to also compare
30+
# with how OpenCV handles this. It also seems to fail to decode the last frame. This library
31+
# provides the most accurate timing information however.
32+
# - OpenCV: Lacks any kind of timebase, only provides position in milliseconds and as frames.
33+
# This is probably sufficient, since we could just use 1ms as a timebase.
34+
# - MoviePy: Assumes fixed framerate and doesn't include timing information. Fixing this is
35+
# probably not feasible, so we should make sure the docs warn users about this.
36+
#
37+
#
38+
@dataclass
39+
class Timecode:
40+
"""Timing information associated with a given frame."""
41+
42+
pts: int
43+
"""Presentation timestamp of the frame in units of `time_base`."""
44+
time_base: Fraction
45+
"""The base unit in which `pts` is measured."""
46+
47+
@property
48+
def seconds(self) -> float:
49+
return float(self.time_base * self.pts)

scenedetect/video_stream.py

Lines changed: 34 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -33,14 +33,12 @@
3333

3434
import typing as ty
3535
from abc import ABC, abstractmethod
36+
from dataclasses import dataclass
3637

3738
import numpy as np
3839

3940
from scenedetect.frame_timecode import FrameTimecode
40-
41-
##
42-
## VideoStream Exceptions
43-
##
41+
from scenedetect.timecode import Timecode
4442

4543

4644
class SeekError(Exception):
@@ -79,6 +77,14 @@ def __init__(self):
7977
##
8078

8179

80+
@dataclass
81+
class VideoFrame:
82+
"""Data returned when reading/decoding a frame from a video."""
83+
84+
image: np.ndarray
85+
timecode: Timecode
86+
87+
8288
class VideoStream(ABC):
8389
"""Interface which all video backends must implement."""
8490

@@ -175,7 +181,31 @@ def frame_number(self) -> int:
175181
#
176182
# Abstract Methods
177183
#
184+
def __iter__(self) -> ty.Iterable[VideoFrame]:
185+
return self
186+
187+
def __next__(self) -> VideoFrame:
188+
"""Read and decode the next frame from the current seek position.
189+
190+
Raises:
191+
StopIteration: The next frame could not be decoded (i.e. the stream ended).
192+
"""
193+
# TODO(v0.7): Make this an abstract method when it is implemented for all backends.
194+
raise NotImplementedError()
195+
196+
def skip(self) -> ty.Optional[Timecode]:
197+
"""Advance the stream to the next frame without decoding the frame data. *May* be faster in
198+
cases where the image data for a given frame isn't required.
199+
200+
Returns:
201+
The `Timecode` of the frame that was skipped, or None if it could not be decoded (i.e.
202+
the stream ended).
203+
"""
204+
# TODO(v0.7): Make this an abstract method when it is implemented for all backends.
205+
raise NotImplementedError()
178206

207+
# TODO(v0.7): Mark this as deprecated in lieu of `__next__` and `skip`. See if there is a way
208+
# we can change this to no longer be an abstract method, but to instead use the above methods.
179209
@abstractmethod
180210
def read(self, decode: bool = True, advance: bool = True) -> ty.Union[np.ndarray, bool]:
181211
"""Read and decode the next frame as a np.ndarray. Returns False when video ends.

tests/conftest.py

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -109,6 +109,12 @@ def test_movie_clip() -> str:
109109
return check_exists("tests/resources/goldeneye.mp4")
110110

111111

112+
@pytest.fixture
113+
def test_vfr_video() -> str:
114+
"""Movie clip containing fast cut, but encoded as variable framerate."""
115+
return check_exists("tests/resources/goldeneye-vfr.mp4")
116+
117+
112118
@pytest.fixture
113119
def corrupt_video_file() -> str:
114120
"""Video containing a corrupted frame causing a decode failure."""

0 commit comments

Comments
 (0)